Files
WP-LIT-MCP/docs/superpowers/plans/2026-09-25-etappe-1b-medien-admin.md

228 KiB
Raw Permalink Blame History

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/<skript>.sh.
  • Ability-Namen lit/<bereich>-<aktion>; 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) <noreply@anthropic.com> und Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7.

Review Focus

  1. Medium wird noch verwendet (Beitragsbild, Divi-Bildmodul "id":"<ID>", wp-image-<ID>, 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-<ID>-Klasse, Divi-5-Attribut "id":"<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<string> – 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<array> $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<array{post_id: int, title: string, post_type: string, where: 'featured'|'content'|'field'|'site_icon'}>; 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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\MediaUsageFinder;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class MediaUsageFinderTest extends TestCase
{
    public function testCombinesFeaturedContentFieldAndSiteIconUsages(): void
    {
        Functions\when('get_post_meta')->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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\MediaPresenter;
use LIT\MCP\Tests\Unit\TestCase;
use stdClass;
use WP_Post;

final class MediaPresenterTest extends TestCase
{
    private function attachment(): WP_Post
    {
        return new WP_Post([
            'ID' => 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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\MediaGetAbility;
use LIT\MCP\Integrations\Media\MediaPresenter;
use LIT\MCP\Integrations\Media\MediaUsageFinder;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;

final class MediaGetAbilityTest extends TestCase
{
    public function testReturnsMetadataWithUsages(): void
    {
        $attachment = new WP_Post(['ID' => 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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Media\MediaIntegration;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class MediaIntegrationTest extends TestCase
{
    /** Grows with Tasks 2–5 – keep in the order of abilities(). */
    private const EXPECTED = [
        'lit/media-get',
    ];

    public function testProvidesTheMediaAbilities(): void
    {
        $integration = new MediaIntegration(Mockery::mock(ContentSchema::class), Mockery::mock(ChangeLog::class), new Limits());

        self::assertSame('media', $integration->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:

    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:

    /**
     * Keys of fields that store attachment IDs (file fields, relations to media) –
     * used by the media usage search.
     *
     * @return list<string>
     */
    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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error;
use WP_Post;

/**
 * Loads an attachment and checks the capability for it (spec §4.5).
 */
final class MediaGuard
{
    public static function attachment(mixed $id, string $capability = 'edit_post'): WP_Post|WP_Error
    {
        $attachmentId = PostGuard::id($id);
        if ($attachmentId === null) {
            return Errors::invalidInput('id fehlt oder ist ungültig.', ['id' => '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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\Abilities\JsonMap;
use WP_Post;

/**
 * Output format of an attachment. Not final so abilities can be tested with a mock.
 */
class MediaPresenter
{
    /** @return array<string, mixed> */
    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<array<string, mixed>> $usages
     * @return array<string, mixed>
     */
    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<string, mixed> */
    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<string, mixed> */
    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<string, mixed> */
    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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use Closure;

/**
 * Finds where an attachment is used (spec §6.3). Deliberately errs on the side of
 * "used": a false positive only asks for force: true, a false negative would break a page.
 * Not final so abilities can be tested with a mock.
 */
class MediaUsageFinder
{
    /**
     * @param Closure(): list<string> $mediaMetaKeys Keys of file/image fields (ContentSchema::mediaFieldKeys()).
     */
    public function __construct(private Closure $mediaMetaKeys, private ?\wpdb $db = null)
    {
    }

    /** @return list<array{post_id: int, title: string, post_type: string, where: string}> */
    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<object> */
    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<object> */
    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<object> */
    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<string, array{post_id: int, title: string, post_type: string, where: string}> $found
     * @param list<object>                                                                    $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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\Abilities\Ability;
use WP_Error;

final class MediaGetAbility implements Ability
{
    public function __construct(private MediaPresenter $presenter, private MediaUsageFinder $finder)
    {
    }

    public function name(): string
    {
        return 'lit/media-get';
    }

    public function label(): string
    {
        return 'Medium ansehen';
    }

    public function description(): string
    {
        return 'Liefert Metadaten eines Mediums: Datei, Maße, Größe, alle Bildgrößen mit URL, Alt-Text, '
            . 'Bildunterschrift, Beschreibung und die Verwendungsorte (Beitragsbild, Inhalte inkl. Divi, '
            . 'Bildfelder, Website-Icon). Vorschaubild über lit-media-preview.';
    }

    public function inputSchema(): array
    {
        return [
            'type' => '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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Limits;

/**
 * Media library tools (spec §6.3). Always available.
 */
final class MediaIntegration implements Integration
{
    public function __construct(private ContentSchema $schema, private ChangeLog $log, private Limits $limits)
    {
    }

    public function id(): string
    {
        return 'media';
    }

    public function isAvailable(): bool
    {
        return true;
    }

    public function abilities(): array
    {
        $presenter = new MediaPresenter();
        $finder = new MediaUsageFinder(fn (): array => $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:

    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
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) <noreply@anthropic.com>
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<summary>, next_cursor: ?string, warnings: list<string>}; 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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Integrations\Media\MediaPresenter;
use LIT\MCP\Integrations\Media\MediaSearchAbility;
use LIT\MCP\Integrations\Media\MediaUsageFinder;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;

final class MediaSearchAbilityTest extends TestCase
{
    private function presenter(): MediaPresenter
    {
        $presenter = Mockery::mock(MediaPresenter::class);
        $presenter->allows('summary')->andReturnUsing(static fn (WP_Post $a): array => ['id' => $a->ID]);

        return $presenter;
    }

    /** @return list<WP_Post> */
    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:

    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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Abilities\Errors;
use WP_Error;
use WP_Post;

final class MediaSearchAbility implements Ability
{
    private const BATCH = 50;
    private const MAX_SCAN = 500;
    private const DATE_PATTERN = '^\d{4}-\d{2}-\d{2}$';

    public function __construct(private MediaPresenter $presenter, private MediaUsageFinder $finder)
    {
    }

    public function name(): string
    {
        return 'lit/media-search';
    }

    public function label(): string
    {
        return 'Medien suchen';
    }

    public function description(): string
    {
        return 'Sucht in der Mediathek nach Text (Titel, Dateiname, Beschreibung), MIME-Typ (z. B. image oder '
            . 'application/pdf) und Zeitraum. missing_alt: Bilder ohne Alt-Text. unused: nirgends verwendete '
            . 'Medien (prüft höchstens 500 Medien je Aufruf, danach mit next_cursor fortsetzen).';
    }

    public function inputSchema(): array
    {
        return [
            'type' => '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<string, mixed> $input
     * @return array<string, mixed>
     */
    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<string, mixed> $args
     * @return array{items: list<WP_Post>, next_cursor: ?string, warnings: list<string>}
     */
    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

        return [
            new MediaSearchAbility($presenter, $finder),
            new MediaGetAbility($presenter, $finder),
        ];
  • Step 4: Run – muss bestehen

Run: composer test && composer lint Expected: PASS.

  • Step 5: Commit
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) <noreply@anthropic.com>
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' => <Bytes>, '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: <Bytes>, 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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\MediaPreviewAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;

final class MediaPreviewAbilityTest extends TestCase
{
    private string $source = '';

    protected function setUp(): void
    {
        parent::setUp();
        $this->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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\MediaDownloadAbility;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;

final class MediaDownloadAbilityTest extends TestCase
{
    private string $file = '';

    protected function setUp(): void
    {
        parent::setUp();
        $this->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:

    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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

/**
 * Media helpers (download_url, wp_tempnam, media_handle_sideload, …) live in
 * wp-admin/includes and are not loaded on REST requests.
 */
final class AdminIncludes
{
    private const FILES = [
        'download_url' => '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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use WP_Error;

final class MediaPreviewAbility implements Ability
{
    private const DEFAULT_SIZE = 512;
    private const EXTENSIONS = [
        'image/jpeg' => '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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Limits;
use WP_Error;

final class MediaDownloadAbility implements Ability
{
    public function __construct(private Limits $limits)
    {
    }

    public function name(): string
    {
        return 'lit/media-download';
    }

    public function label(): string
    {
        return 'Medium herunterladen';
    }

    public function description(): string
    {
        return 'Liefert eine Datei der Mediathek (Original oder Bildgröße, siehe sizes in lit-media-get) als '
            . 'eingebettete Ressource. Dateien über dem Upload-Limit werden nicht übertragen; dann die URL nutzen.';
    }

    public function inputSchema(): array
    {
        return [
            'type' => '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

        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
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) <noreply@anthropic.com>
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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\MediaImporter;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Error;

final class MediaImporterTest extends TestCase
{
    /** @var list<string> */
    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('<?php evil();'), 'bild.png', []);

        self::assertSame('lit_validation_failed', $result->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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Media\MediaImporter;
use LIT\MCP\Integrations\Media\MediaPresenter;
use LIT\MCP\Integrations\Media\MediaUploadAbility;
use LIT\MCP\Integrations\Media\MediaUploadFromUrlAbility;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;

final class MediaUploadAbilitiesTest extends TestCase
{
    private function presenter(): MediaPresenter
    {
        $presenter = Mockery::mock(MediaPresenter::class);
        $presenter->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:

    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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\Abilities\Errors;
use LIT\MCP\Limits;
use WP_Error;

/**
 * Imports files into the media library (spec §6.3, §10): SSRF-safe download,
 * MIME whitelist checked against the real file content, size limit. The temp
 * file is removed on every error path. Not final so abilities can be tested with a mock.
 */
class MediaImporter
{
    public const MIMES = [
        'jpg|jpeg|jpe' => '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<string, string> */
    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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
use WP_Post;

final class MediaUploadFromUrlAbility implements Ability
{
    public function __construct(
        private MediaImporter $importer,
        private MediaPresenter $presenter,
        private ChangeLog $log
    ) {
    }

    public function name(): string
    {
        return 'lit/media-upload-from-url';
    }

    public function label(): string
    {
        return 'Medium von URL importieren';
    }

    public function description(): string
    {
        return 'Lädt eine öffentlich erreichbare Datei (Bild, PDF, MP4, MP3) von einer URL in die Mediathek; '
            . 'optional mit Titel, Alt-Text und Zuordnung zu einem Eintrag (post_id).';
    }

    public function inputSchema(): array
    {
        return [
            'type' => '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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Limits;
use WP_Error;
use WP_Post;

final class MediaUploadAbility implements Ability
{
    public function __construct(
        private MediaImporter $importer,
        private MediaPresenter $presenter,
        private ChangeLog $log,
        private Limits $limits
    ) {
    }

    public function name(): string
    {
        return 'lit/media-upload';
    }

    public function label(): string
    {
        return 'Medium hochladen';
    }

    public function description(): string
    {
        return sprintf(
            'Lädt eine Datei (Bild, PDF, MP4, MP3) als Base64 in die Mediathek, höchstens %d MB; '
                . 'optional mit Titel, Alt-Text und Zuordnung zu einem Eintrag (post_id).',
            $this->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

    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
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) <noreply@anthropic.com>
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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Media\MediaUpdateAbility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;

final class MediaUpdateAbilityTest extends TestCase
{
    public function testUpdatesEachItemAndReportsPerItem(): void
    {
        Functions\when('get_post')->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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Media\MediaDeleteAbility;
use LIT\MCP\Integrations\Media\MediaUsageFinder;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;

final class MediaDeleteAbilityTest extends TestCase
{
    private const USAGE = [['post_id' => 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:

    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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;

final class MediaUpdateAbility implements Ability
{
    private const FIELDS = ['title' => '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<string, mixed> $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

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
use WP_Post;

final class MediaDeleteAbility implements Ability
{
    /** @var callable(): bool */
    private $mediaTrash;

    public function __construct(
        private MediaUsageFinder $finder,
        private ChangeLog $log,
        ?callable $mediaTrash = null
    ) {
        $this->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:

            new MediaUpdateAbility($this->log),
            new MediaDeleteAbility($finder, $this->log),
  • Step 4: Run – muss bestehen

Run: composer test && composer lint Expected: PASS.

  • Step 5: Commit
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) <noreply@anthropic.com>
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<Ability> $abilities): list<Ability>; directNames(list<Ability> $abilities): list<string> (Reihenfolge der Registry); static isAllowed(string $name, string $mode): bool; static sanitize(mixed $input): array<string, string> (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

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Abilities;

use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class ExposurePolicyTest extends TestCase
{
    private function ability(string $name, bool $direct): Ability
    {
        $ability = Mockery::mock(Ability::class);
        $ability->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',
                '<script>' => 'direct',
                'lit/x' => ['direct'],
            ])
        );
        self::assertSame([], ExposurePolicy::sanitize('garbage'));
    }

    public function testFromOptionToleratesACorruptOption(): void
    {
        Functions\when('get_option')->justReturn('garbage');

        self::assertSame('direct', ExposurePolicy::fromOption()->mode($this->ability('lit/a', true)));
    }
}

In tests/Unit/Abilities/AbilityRegistrarTest.php ergänzen (Import use LIT\MCP\Abilities\ExposurePolicy;):

    public function testDisabledAbilitiesAreNotRegistered(): void
    {
        $ability = $this->ability();
        $integration = Mockery::mock(Integration::class);
        $integration->allows('id')->andReturn('core');
        $integration->allows('isAvailable')->andReturn(true);
        $integration->allows('abilities')->andReturn([$ability]);
        Functions\expect('wp_register_ability')->never();

        (new AbilityRegistrar(new IntegrationRegistry($integration), new ExposurePolicy(['lit/test' => 'disabled'])))
            ->registerAbilities();
    }

In tests/Unit/Server/ServerRegistrarTest.php ergänzen (Import use LIT\MCP\Abilities\ExposurePolicy;):

    public function testExposurePolicyDecidesWhichToolsAreDirect(): void
    {
        $policy = new ExposurePolicy(['lit/guide' => 'indirect', 'lit/rare-tool' => 'direct']);
        $registrar = new ServerRegistrar($this->registry(), $this->instructions(), static fn () => true, null, null, $policy);

        self::assertSame(
            ['lit/rare-tool', 'mcp-adapter/discover-abilities', 'mcp-adapter/get-ability-info', 'mcp-adapter/execute-ability'],
            $registrar->serverArguments()['tools']
        );
    }

In tests/Unit/Integrations/IntegrationRegistryTest.php die Zeile self::assertSame(['lit/a'], $registry->directAbilityNames()); entfernen.

  • Step 2: Run – muss fehlschlagen

Run: composer test -- --filter 'ExposurePolicyTest|AbilityRegistrarTest|ServerRegistrarTest|IntegrationRegistryTest' Expected: FAIL (ExposurePolicy fehlt; Konstruktoren kennen den Parameter nicht).

  • Step 3: Implementierung

src/Abilities/ExposurePolicy.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Abilities;

/**
 * Decides per ability whether it is a direct MCP tool, only reachable through the
 * adapter's meta-tools, or disabled (spec §4.3). Ability::isDirect() is the default.
 */
final class ExposurePolicy
{
    public const OPTION = 'lit_mcp_exposure';
    public const DIRECT = 'direct';
    public const INDIRECT = 'indirect';
    public const DISABLED = 'disabled';
    public const MODES = [self::DIRECT, self::INDIRECT, self::DISABLED];

    /** Image results only work for direct tools. */
    public const DIRECT_ONLY = ['lit/media-preview'];

    /** Entry point for every conversation – cannot be switched off. */
    public const REQUIRED = ['lit/guide'];

    /** @param array<string, string> $overrides Ability name => mode. */
    public function __construct(private array $overrides = [])
    {
    }

    public static function fromOption(): self
    {
        return new self(self::sanitize(get_option(self::OPTION, [])));
    }

    public function mode(Ability $ability): string
    {
        $mode = $this->overrides[$ability->name()] ?? null;

        return is_string($mode) && self::isAllowed($ability->name(), $mode) ? $mode : $this->defaultMode($ability);
    }

    public function defaultMode(Ability $ability): string
    {
        return $ability->isDirect() ? self::DIRECT : self::INDIRECT;
    }

    /**
     * @param list<Ability> $abilities
     * @return list<Ability>
     */
    public function enabled(array $abilities): array
    {
        return array_values(array_filter(
            $abilities,
            fn (Ability $ability): bool => $this->mode($ability) !== self::DISABLED
        ));
    }

    /**
     * @param list<Ability> $abilities
     * @return list<string>
     */
    public function directNames(array $abilities): array
    {
        $names = [];
        foreach ($abilities as $ability) {
            if ($this->mode($ability) === self::DIRECT) {
                $names[] = $ability->name();
            }
        }

        return $names;
    }

    public static function isAllowed(string $name, string $mode): bool
    {
        if (! in_array($mode, self::MODES, true)) {
            return false;
        }

        if ($mode !== self::DIRECT && in_array($name, self::DIRECT_ONLY, true)) {
            return $mode === self::DISABLED;
        }

        return ! ($mode === self::DISABLED && in_array($name, self::REQUIRED, true));
    }

    /**
     * Settings API sanitize callback for the option.
     *
     * @return array<string, string>
     */
    public static function sanitize(mixed $input): array
    {
        if (! is_array($input)) {
            return [];
        }

        $clean = [];
        foreach ($input as $name => $mode) {
            if (
                is_string($name)
                && is_string($mode)
                && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $name) === 1
                && self::isAllowed($name, $mode)
            ) {
                $clean[$name] = $mode;
            }
        }
        ksort($clean);

        return $clean;
    }
}

src/Abilities/AbilityRegistrar.php: Konstruktor und registerAbilities() ersetzen:

    public function __construct(private IntegrationRegistry $registry, private ?ExposurePolicy $policy = null)
    {
    }
    public function registerAbilities(): void
    {
        $policy = $this->policy ?? new ExposurePolicy();
        foreach ($policy->enabled($this->registry->abilities()) as $ability) {
            wp_register_ability($ability->name(), $this->argsFor($ability));
        }
    }

src/Server/ServerRegistrar.php: Import use LIT\MCP\Abilities\ExposurePolicy; ergänzen; im Konstruktor als letzten Parameter ?ExposurePolicy $policy = null anhängen, eine Property private ExposurePolicy $policy; deklarieren und im Konstruktorrumpf $this->policy = $policy ?? new ExposurePolicy(); setzen. Der Konstruktor lautet danach:

    private ExposurePolicy $policy;

    public function __construct(
        private IntegrationRegistry $registry,
        private InstructionsBuilder $instructions,
        ?callable $oauthEnabled = null,
        ?callable $createAdapterServer = null,
        ?callable $classExists = null,
        ?ExposurePolicy $policy = null
    ) {
        $this->policy = $policy ?? new ExposurePolicy();
        $this->oauthEnabled = $oauthEnabled
            ?? static fn (): bool => (new OAuthContext())->is_enabled();
        $this->classExists = $classExists
            ?? static fn (string $class): bool => class_exists($class);
        $this->createAdapterServer = $createAdapterServer
            ?? static function (array $args): mixed {
                return McpAdapter::instance()->create_server(
                    $args['id'],
                    $args['namespace'],
                    $args['route'],
                    $args['name'],
                    $args['description'],
                    $args['version'],
                    [OAuthHttpTransport::class],
                    ErrorLogMcpErrorHandler::class,
                    (new OAuthContext())->observability_handler(),
                    $args['tools']
                );
            };
    }

und in serverArguments() die Zeile 'tools' => … ersetzen durch:

            'tools' => array_merge($this->policy->directNames($this->registry->abilities()), self::META_TOOLS),

src/Integrations/IntegrationRegistry.php: die Methode directAbilityNames() samt DocBlock löschen.

src/Plugin.php: Import use LIT\MCP\Abilities\ExposurePolicy; ergänzen und in boot() die beiden Registrar-Zeilen ersetzen durch:

        $policy = ExposurePolicy::fromOption();
        (new AbilityRegistrar($registry, $policy))->register();
        (new ServerRegistrar(
            $registry,
            new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames()),
            null,
            null,
            null,
            $policy
        ))->register();
  • Step 4: Run – muss bestehen

Run: composer test && composer lint Expected: PASS (alle bisherigen ServerRegistrarTest-Fälle laufen ohne Policy mit dem isDirect()-Standard weiter).

  • Step 5: Commit
git add src/Abilities src/Server/ServerRegistrar.php src/Integrations/IntegrationRegistry.php src/Plugin.php tests/Unit/Abilities tests/Unit/Server/ServerRegistrarTest.php tests/Unit/Integrations/IntegrationRegistryTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: configurable exposure policy for direct, indirect and disabled tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"

Task 7: Sichtbarkeit – keine Tool-Liste und keine Details für nicht freigegebene Benutzer

Aus Etappe 0 offen (b): Der Adapter 0.5 bietet dafür die Filter mcp_adapter_tools_list ($tools, McpServer $server) und mcp_adapter_initialize_response (InitializeResult $result, McpServer $server), beide nach der Token-Prüfung (der Benutzer ist gesetzt). Für Benutzer ohne lit_mcp_use liefert unser Server eine leere Tool-Liste und statt der Instructions nur einen Hinweis, wie der Zugang freigeschaltet wird. Andere MCP-Server bleiben unberührt. (Prompts gibt es in dieser Etappe noch nicht; mcp_adapter_prompts_list wird in Etappe 4 genauso gefiltert.)

Files:

  • Create: src/Server/VisibilityFilter.php
  • Modify: src/Plugin.php (boot(): registrieren)
  • Test: tests/Unit/Server/VisibilityFilterTest.php

Interfaces:

  • Consumes: Access::currentUserMayUse(), ServerRegistrar::SERVER_ID; WP\MCP\Core\McpServer::get_server_id(); WP\McpSchema\Common\Protocol\DTO\InitializeResult::fromArray()/toArray()/getInstructions().

  • Produces: VisibilityFilter (final): NO_ACCESS_INSTRUCTIONS; register(): void; filterTools(mixed $tools, mixed $server): mixed; filterInitialize(mixed $result, mixed $server): mixed.

  • Step 1: Failing Test

tests/Unit/Server/VisibilityFilterTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Server;

use Brain\Monkey\Functions;
use LIT\MCP\Server\VisibilityFilter;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP\MCP\Core\McpServer;
use WP\McpSchema\Common\Protocol\DTO\InitializeResult;

final class VisibilityFilterTest extends TestCase
{
    private function server(string $id): McpServer
    {
        $server = Mockery::mock(McpServer::class);
        $server->allows('get_server_id')->andReturn($id);

        return $server;
    }

    private function initializeResult(): InitializeResult
    {
        return InitializeResult::fromArray([
            'protocolVersion' => '2025-11-25',
            'capabilities' => ['tools' => ['listChanged' => false]],
            'serverInfo' => ['name' => 'LIT MCP', 'version' => '0.2.0'],
            'instructions' => 'Inhaltstypen: page, post, geheim',
        ]);
    }

    public function testUsersWithoutAccessSeeNoToolsOnOurServer(): void
    {
        Functions\when('current_user_can')->justReturn(false);
        $filter = new VisibilityFilter();

        self::assertSame([], $filter->filterTools(['tool-a'], $this->server('mcp-oauth-server')));
        self::assertSame(['tool-a'], $filter->filterTools(['tool-a'], $this->server('other-server')));
    }

    public function testAllowedUsersKeepTheToolList(): void
    {
        Functions\when('current_user_can')->justReturn(true);

        self::assertSame(['tool-a'], (new VisibilityFilter())->filterTools(['tool-a'], $this->server('mcp-oauth-server')));
    }

    public function testInstructionsAreReplacedForUsersWithoutAccess(): void
    {
        Functions\when('current_user_can')->justReturn(false);

        $result = (new VisibilityFilter())->filterInitialize($this->initializeResult(), $this->server('mcp-oauth-server'));

        self::assertInstanceOf(InitializeResult::class, $result);
        self::assertSame(VisibilityFilter::NO_ACCESS_INSTRUCTIONS, $result->getInstructions());
        self::assertSame('2025-11-25', $result->getProtocolVersion());
    }

    public function testInstructionsStayForAllowedUsersAndOtherServers(): void
    {
        Functions\when('current_user_can')->justReturn(true);
        $original = $this->initializeResult();

        self::assertSame($original, (new VisibilityFilter())->filterInitialize($original, $this->server('mcp-oauth-server')));

        Functions\when('current_user_can')->justReturn(false);
        self::assertSame($original, (new VisibilityFilter())->filterInitialize($original, $this->server('other-server')));
    }

    public function testRegisterHooksBothFilters(): void
    {
        $filter = new VisibilityFilter();
        Functions\expect('add_filter')->once()->with('mcp_adapter_tools_list', [$filter, 'filterTools'], 10, 2);
        Functions\expect('add_filter')->once()->with('mcp_adapter_initialize_response', [$filter, 'filterInitialize'], 10, 2);

        $filter->register();
    }
}
  • Step 2: Run – muss fehlschlagen

Run: composer test -- --filter VisibilityFilterTest Expected: FAIL, Klasse fehlt.

  • Step 3: Implementierung

src/Server/VisibilityFilter.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Server;

use LIT\MCP\Access;
use WP\MCP\Core\McpServer;
use WP\McpSchema\Common\Protocol\DTO\InitializeResult;

/**
 * Users who may connect (mcp-oauth) but are not allowed to use LIT MCP get neither
 * the tool list nor the site-specific instructions of our server. Execution is
 * denied anyway (Access + AbilityRegistrar); this only avoids leaking details.
 */
final class VisibilityFilter
{
    public const NO_ACCESS_INSTRUCTIONS = 'Dein WordPress-Benutzerkonto ist für LIT MCP nicht freigegeben. '
        . 'Ein Administrator kann deine Rolle unter Einstellungen → LIT MCP → Zugriff freigeben.';

    public function register(): void
    {
        add_filter('mcp_adapter_tools_list', [$this, 'filterTools'], 10, 2);
        add_filter('mcp_adapter_initialize_response', [$this, 'filterInitialize'], 10, 2);
    }

    public function filterTools(mixed $tools, mixed $server): mixed
    {
        return $this->hides($server) ? [] : $tools;
    }

    public function filterInitialize(mixed $result, mixed $server): mixed
    {
        if (! $result instanceof InitializeResult || ! $this->hides($server)) {
            return $result;
        }

        $data = $result->toArray();
        $data['instructions'] = self::NO_ACCESS_INSTRUCTIONS;

        return InitializeResult::fromArray($data);
    }

    private function hides(mixed $server): bool
    {
        return $server instanceof McpServer
            && $server->get_server_id() === ServerRegistrar::SERVER_ID
            && ! Access::currentUserMayUse();
    }
}

src/Plugin.php: Import use LIT\MCP\Server\VisibilityFilter; ergänzen und in boot() direkt nach (new RequestContext())->register(); einfügen:

        (new VisibilityFilter())->register();
  • Step 4: Run – muss bestehen

Run: composer test && composer lint Expected: PASS.

  • Step 5: Integration – Abonnent sieht nichts, Administrator alles

Run:

cd /home/janniclas/Projekte/WordPress
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh

Expected: ALL CHECKS PASSED (Rollen-Gate unverändert). Die gezielte Prüfung der leeren Liste folgt im Ende-zu-Ende-Test (Task 13).

  • Step 6: Commit
git add src/Server/VisibilityFilter.php src/Plugin.php tests/Unit/Server/VisibilityFilterTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: hide tool list and instructions from users without LIT MCP access

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"

Task 8: Einstellungsseite (Gerüst) mit Tab „Verbindung“, Admin-Hinweise, Site-Health-Test

Spec §8.4 „Verbindung“: Status Adapter/OAuth, MCP-Server-URL zum Kopieren, Prüfung „Discovery-Dokumente erreichbar, 401 mit WWW-Authenticate“. Aus Etappe 0 offen (d): Hinweis, wenn mcp-oauth deaktiviert ist oder /.well-known/ nicht erreichbar ist. mcp-oauth bringt bereits einen Site-Health-Test für die Discovery-Dokumente mit (HealthCheck, Transient wpmedia_mcp_oauth_wellknown_health_check); LIT MCP ergänzt einen eigenen Test für den Endpunkt und nutzt das gecachte Discovery-Ergebnis für Admin-Hinweise (keine HTTP-Anfrage bei jedem Seitenaufruf).

Files:

  • Create: src/Admin/AdminTab.php, src/Admin/SettingsPage.php, src/Admin/ConnectionStatus.php, src/Admin/ConnectionTab.php, src/Admin/AdminNotices.php, src/Admin/SiteHealth.php
  • Modify: src/Plugin.php (boot(), statische Property + settingsPage())
  • Test: tests/Unit/Admin/SettingsPageTest.php, tests/Unit/Admin/ConnectionStatusTest.php, tests/Unit/Admin/AdminNoticesTest.php, tests/Unit/Admin/SiteHealthTest.php

Interfaces:

  • Consumes: ServerRegistrar::NAMESPACE/ROUTE; WPMedia\MCP\OAuth\Context::is_enabled(); WPMedia\MCP\OAuth\Auth\Discovery\HealthCheck (TRANSIENT_KEY, run_self_check(): array{label, status, …}); WP\MCP\Core\McpAdapter.

  • Produces:

    • AdminTab (Interface): slug(): string, label(): string, registerSettings(): void (auf admin_init), registerActions(): void (admin-post-Handler, beim Registrieren der Seite), render(): void.
    • SettingsPage (final): SLUG = 'lit-mcp'; __construct(list<AdminTab> $tabs) (erster = Standard); register(), addMenu(), registerSettings(), tabSlugs(): list<string>, currentTab(): string, render(); static url(string $tab = '', array $args = []): string. Hinweise über ?lit_mcp_notice=revoked|revoke_failed|cleared.
    • ConnectionStatus (nicht final): ENDPOINT_TRANSIENT = 'lit_mcp_endpoint_check'; __construct(?callable $oauthEnabled = null); static serverUrl(): string; oauthEnabled(), adapterLoaded(), https(): bool; discovery(bool $allowFetch): ?array; endpoint(bool $allowFetch): ?array{ok: bool, message: string} (5 Minuten gecacht); checks(bool $allowFetch): list<array{label: string, ok: ?bool, message: string}>.
    • ConnectionTab(ConnectionStatus $status) – slug verbindung.
    • AdminNotices(ConnectionStatus $status): register(), messages(bool $onOwnPage): list<string>, render().
    • SiteHealth(ConnectionStatus $status): TEST = 'lit_mcp_endpoint'; register(), addTest(mixed $tests): mixed, run(): array.
    • Plugin::settingsPage(): ?SettingsPage (für den Render-Test in Task 13).
  • Step 1: Failing Tests

tests/Unit/Admin/SettingsPageTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Admin;

use Brain\Monkey\Functions;
use LIT\MCP\Admin\AdminTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class SettingsPageTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Functions\stubEscapeFunctions();
        Functions\stubTranslationFunctions();
        Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
        Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
        Functions\when('sanitize_key')->alias(static fn (string $key): string => strtolower((string) preg_replace('/[^a-z0-9_\-]/i', '', $key)));
    }

    protected function tearDown(): void
    {
        unset($_GET['tab'], $_GET['lit_mcp_notice']);
        parent::tearDown();
    }

    private function tab(string $slug, string $label): AdminTab
    {
        $tab = Mockery::mock(AdminTab::class);
        $tab->allows('slug')->andReturn($slug);
        $tab->allows('label')->andReturn($label);
        $tab->allows('registerActions');
        $tab->allows('render')->andReturnUsing(static function () use ($slug): void {
            echo '<p>Inhalt ' . $slug . '</p>';
        });

        return $tab;
    }

    public function testRendersTabsAndTheRequestedTab(): void
    {
        Functions\when('current_user_can')->justReturn(true);
        $_GET['tab'] = 'zugriff';
        $page = new SettingsPage([$this->tab('verbindung', 'Verbindung'), $this->tab('zugriff', 'Zugriff')]);

        ob_start();
        $page->render();
        $html = (string) ob_get_clean();

        self::assertStringContainsString('Inhalt zugriff', $html);
        self::assertStringNotContainsString('Inhalt verbindung', $html);
        self::assertMatchesRegularExpression('/tab=zugriff" class="nav-tab nav-tab-active"/', $html);
        self::assertSame(['verbindung', 'zugriff'], $page->tabSlugs());
    }

    public function testUnknownTabFallsBackToTheFirstAndNoticesAreShown(): void
    {
        Functions\when('current_user_can')->justReturn(true);
        $_GET['tab'] = '../../evil';
        $_GET['lit_mcp_notice'] = 'revoked';
        $page = new SettingsPage([$this->tab('verbindung', 'Verbindung')]);

        ob_start();
        $page->render();
        $html = (string) ob_get_clean();

        self::assertSame('verbindung', $page->currentTab());
        self::assertStringContainsString('Inhalt verbindung', $html);
        self::assertStringContainsString('notice-success', $html);
    }

    public function testNothingIsRenderedWithoutManageOptions(): void
    {
        Functions\when('current_user_can')->justReturn(false);

        ob_start();
        (new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->render();

        self::assertSame('', ob_get_clean());
    }

    public function testRegisterHooksMenuSettingsAndTabActions(): void
    {
        $tab = Mockery::mock(AdminTab::class);
        $tab->allows('slug')->andReturn('verbindung');
        $tab->expects('registerActions')->once();
        $page = new SettingsPage([$tab]);
        Functions\expect('add_action')->once()->with('admin_menu', [$page, 'addMenu']);
        Functions\expect('add_action')->once()->with('admin_init', [$page, 'registerSettings']);

        $page->register();
    }
}

tests/Unit/Admin/ConnectionStatusTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Admin;

use Brain\Monkey\Functions;
use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class ConnectionStatusTest extends TestCase
{
    public function testEndpointCheckExpects401WithBearerChallengeAndCachesIt(): void
    {
        Functions\when('get_transient')->justReturn(false);
        Functions\when('rest_url')->justReturn('https://kunde.de/wp-json/mcp/mcp-oauth-server');
        Functions\expect('wp_remote_post')->once()->with('https://kunde.de/wp-json/mcp/mcp-oauth-server', Mockery::type('array'))
            ->andReturn(['response' => ['code' => 401]]);
        Functions\when('wp_remote_retrieve_response_code')->justReturn(401);
        Functions\when('wp_remote_retrieve_header')->justReturn('Bearer realm="https://kunde.de"');
        Functions\expect('set_transient')->once()
            ->with('lit_mcp_endpoint_check', Mockery::on(static fn (array $value): bool => $value['ok'] === true), 300);

        $result = (new ConnectionStatus(static fn (): bool => true))->endpoint(true);

        self::assertTrue($result['ok']);
    }

    public function testEndpointAnsweringWithoutChallengeIsReported(): void
    {
        Functions\when('get_transient')->justReturn(false);
        Functions\when('rest_url')->justReturn('https://kunde.de/wp-json/mcp/mcp-oauth-server');
        Functions\when('wp_remote_post')->justReturn(['response' => ['code' => 200]]);
        Functions\when('wp_remote_retrieve_response_code')->justReturn(200);
        Functions\when('wp_remote_retrieve_header')->justReturn('');
        Functions\when('set_transient')->justReturn(true);

        $result = (new ConnectionStatus(static fn (): bool => true))->endpoint(true);

        self::assertFalse($result['ok']);
        self::assertStringContainsString('200', $result['message']);
    }

    public function testChecksWithoutFetchingUseOnlyCachedResults(): void
    {
        Functions\when('get_transient')->justReturn(false);
        Functions\expect('wp_remote_post')->never();
        Functions\when('home_url')->justReturn('http://kunde.test');

        $checks = (new ConnectionStatus(static fn (): bool => false))->checks(false);

        $byLabel = array_column($checks, null, 'label');
        self::assertFalse($byLabel['OAuth-Server']['ok']);
        self::assertFalse($byLabel['HTTPS']['ok']);
        self::assertNull($byLabel['Discovery (/.well-known/)']['ok']);
        self::assertNull($byLabel['Endpunkt ohne Token']['ok']);
    }
}

tests/Unit/Admin/AdminNoticesTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Admin;

use Brain\Monkey\Functions;
use LIT\MCP\Admin\AdminNotices;
use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class AdminNoticesTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Functions\stubTranslationFunctions();
    }

    private function status(bool $oauth, ?array $discovery, bool $https): ConnectionStatus
    {
        $status = Mockery::mock(ConnectionStatus::class);
        $status->allows('oauthEnabled')->andReturn($oauth);
        $status->allows('discovery')->with(false)->andReturn($discovery);
        $status->allows('https')->andReturn($https);

        return $status;
    }

    public function testWarnsAboutDisabledOAuthAndBrokenDiscoveryEverywhere(): void
    {
        $messages = (new AdminNotices($this->status(false, ['status' => 'critical'], true)))->messages(false);

        self::assertCount(2, $messages);
        self::assertStringContainsString('OAuth-Server ist deaktiviert', $messages[0]);
        self::assertStringContainsString('/.well-known/', $messages[1]);
    }

    public function testHttpsHintOnlyOnTheSettingsPage(): void
    {
        $notices = new AdminNotices($this->status(true, null, false));

        self::assertSame([], $notices->messages(false));
        self::assertCount(1, $notices->messages(true));
    }
}

tests/Unit/Admin/SiteHealthTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Admin;

use Brain\Monkey\Functions;
use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\Admin\SiteHealth;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class SiteHealthTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Functions\stubEscapeFunctions();
        Functions\stubTranslationFunctions();
        Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
        Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
    }

    public function testAddsADirectTest(): void
    {
        $health = new SiteHealth(Mockery::mock(ConnectionStatus::class));

        $tests = $health->addTest(['direct' => []]);

        self::assertSame([$health, 'run'], $tests['direct']['lit_mcp_endpoint']['test']);
    }

    public function testGoodWhenOAuthIsOnAndTheEndpointChallenges(): void
    {
        $status = Mockery::mock(ConnectionStatus::class);
        $status->allows('oauthEnabled')->andReturn(true);
        $status->allows('endpoint')->with(true)->andReturn(['ok' => true, 'message' => '401 mit WWW-Authenticate']);

        $result = (new SiteHealth($status))->run();

        self::assertSame('good', $result['status']);
        self::assertSame('lit_mcp_endpoint', $result['test']);
    }

    public function testCriticalWhenOAuthIsDisabled(): void
    {
        $status = Mockery::mock(ConnectionStatus::class);
        $status->allows('oauthEnabled')->andReturn(false);

        $result = (new SiteHealth($status))->run();

        self::assertSame('critical', $result['status']);
        self::assertStringContainsString('deaktiviert', $result['description']);
    }
}
  • Step 2: Run – muss fehlschlagen

Run: composer test -- --filter 'Admin' Expected: FAIL, Klassen fehlen.

  • Step 3: Implementierung

src/Admin/AdminTab.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

/**
 * One tab of the settings page "Einstellungen → LIT MCP".
 */
interface AdminTab
{
    public function slug(): string;

    public function label(): string;

    /** Registers Settings API options; runs on admin_init. */
    public function registerSettings(): void;

    /** Registers admin-post.php handlers; runs when the page is registered. */
    public function registerActions(): void;

    public function render(): void;
}

src/Admin/SettingsPage.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

/**
 * Settings page "Einstellungen → LIT MCP" with tabs (spec §8.4). Settings API, no JS framework.
 */
final class SettingsPage
{
    public const SLUG = 'lit-mcp';

    /** @var array<string, AdminTab> */
    private array $tabs = [];

    /** @param list<AdminTab> $tabs The first tab is the default. */
    public function __construct(array $tabs)
    {
        foreach ($tabs as $tab) {
            $this->tabs[$tab->slug()] = $tab;
        }
    }

    public function register(): void
    {
        add_action('admin_menu', [$this, 'addMenu']);
        add_action('admin_init', [$this, 'registerSettings']);
        foreach ($this->tabs as $tab) {
            $tab->registerActions();
        }
    }

    public function addMenu(): void
    {
        add_options_page(
            __('LIT MCP', 'wp-lit-mcp'),
            __('LIT MCP', 'wp-lit-mcp'),
            'manage_options',
            self::SLUG,
            [$this, 'render']
        );
    }

    public function registerSettings(): void
    {
        foreach ($this->tabs as $tab) {
            $tab->registerSettings();
        }
    }

    /** @return list<string> */
    public function tabSlugs(): array
    {
        return array_keys($this->tabs);
    }

    public function currentTab(): string
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch.
        $requested = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : '';

        return isset($this->tabs[$requested]) ? $requested : (string) array_key_first($this->tabs);
    }

    /** @param array<string, string> $args */
    public static function url(string $tab = '', array $args = []): string
    {
        $query = array_merge(['page' => self::SLUG], $tab !== '' ? ['tab' => $tab] : [], $args);

        return add_query_arg($query, admin_url('options-general.php'));
    }

    public function render(): void
    {
        if (! current_user_can('manage_options')) {
            return;
        }

        $current = $this->currentTab();
        echo '<div class="wrap"><h1>' . esc_html__('LIT MCP', 'wp-lit-mcp') . '</h1>';
        $this->renderNotice();
        echo '<nav class="nav-tab-wrapper">';
        foreach ($this->tabs as $slug => $tab) {
            printf(
                '<a href="%s" class="nav-tab%s">%s</a>',
                esc_url(self::url($slug)),
                $slug === $current ? ' nav-tab-active' : '',
                esc_html($tab->label())
            );
        }
        echo '</nav>';
        $this->tabs[$current]->render();
        echo '</div>';
    }

    private function renderNotice(): void
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
        $key = isset($_GET['lit_mcp_notice']) ? sanitize_key(wp_unslash((string) $_GET['lit_mcp_notice'])) : '';
        $notice = match ($key) {
            'revoked' => ['success', __('Verbindung widerrufen.', 'wp-lit-mcp')],
            'revoke_failed' => ['error', __('Die Verbindung konnte nicht widerrufen werden.', 'wp-lit-mcp')],
            'cleared' => ['success', __('Protokoll geleert.', 'wp-lit-mcp')],
            default => null,
        };

        if ($notice !== null) {
            printf(
                '<div class="notice notice-%s is-dismissible"><p>%s</p></div>',
                esc_attr($notice[0]),
                esc_html($notice[1])
            );
        }
    }
}

src/Admin/ConnectionStatus.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

use LIT\MCP\Server\ServerRegistrar;
use WP\MCP\Core\McpAdapter;
use WPMedia\MCP\OAuth\Auth\Discovery\HealthCheck;
use WPMedia\MCP\OAuth\Context as OAuthContext;

/**
 * Connection checks for the settings page, admin notices and Site Health.
 * Not final so notices and Site Health can be tested with a mock.
 */
class ConnectionStatus
{
    public const ENDPOINT_TRANSIENT = 'lit_mcp_endpoint_check';
    private const CACHE_SECONDS = 300;

    /** @var callable(): bool */
    private $oauthEnabled;

    public function __construct(?callable $oauthEnabled = null)
    {
        $this->oauthEnabled = $oauthEnabled ?? static fn (): bool => (new OAuthContext())->is_enabled();
    }

    public static function serverUrl(): string
    {
        return rest_url(ServerRegistrar::NAMESPACE . '/' . ServerRegistrar::ROUTE);
    }

    public function oauthEnabled(): bool
    {
        return ($this->oauthEnabled)();
    }

    public function adapterLoaded(): bool
    {
        return class_exists(McpAdapter::class);
    }

    public function https(): bool
    {
        return str_starts_with((string) home_url(), 'https://');
    }

    /** @return array<string, mixed>|null mcp-oauth's discovery self check (label, status, …). */
    public function discovery(bool $allowFetch): ?array
    {
        if (! $allowFetch) {
            $cached = get_transient(HealthCheck::TRANSIENT_KEY);

            return is_array($cached) ? $cached : null;
        }

        return (new HealthCheck(new OAuthContext()))->run_self_check();
    }

    /** @return array{ok: bool, message: string}|null */
    public function endpoint(bool $allowFetch): ?array
    {
        $cached = get_transient(self::ENDPOINT_TRANSIENT);
        if (is_array($cached)) {
            return $cached;
        }

        if (! $allowFetch) {
            return null;
        }

        $response = wp_remote_post(self::serverUrl(), [
            'timeout' => 5,
            'headers' => ['Content-Type' => 'application/json'],
            'body' => '{"jsonrpc":"2.0","id":1,"method":"ping"}',
        ]);

        if (is_wp_error($response)) {
            $result = ['ok' => false, 'message' => 'Endpunkt nicht erreichbar: ' . $response->get_error_message()];
        } else {
            $code = (int) wp_remote_retrieve_response_code($response);
            $challenge = (string) wp_remote_retrieve_header($response, 'www-authenticate');
            $result = $code === 401 && str_starts_with($challenge, 'Bearer')
                ? ['ok' => true, 'message' => 'Ohne Token: 401 mit WWW-Authenticate – korrekt.']
                : ['ok' => false, 'message' => sprintf('Erwartet 401 mit WWW-Authenticate, erhalten HTTP %d.', $code)];
        }

        set_transient(self::ENDPOINT_TRANSIENT, $result, self::CACHE_SECONDS);

        return $result;
    }

    /** @return list<array{label: string, ok: ?bool, message: string}> */
    public function checks(bool $allowFetch): array
    {
        $adapter = $this->adapterLoaded();
        $oauth = $this->oauthEnabled();
        $https = $this->https();
        $discovery = $this->discovery($allowFetch);
        $endpoint = $this->endpoint($allowFetch);

        return [
            ['label' => 'MCP-Adapter', 'ok' => $adapter, 'message' => $adapter ? 'geladen' : 'fehlt'],
            [
                'label' => 'OAuth-Server',
                'ok' => $oauth,
                'message' => $oauth ? 'aktiv' : 'deaktiviert (Filter wpmedia_mcp_oauth_server_enabled)',
            ],
            [
                'label' => 'HTTPS',
                'ok' => $https,
                'message' => $https ? 'aktiv' : 'Website läuft ohne HTTPS – claude.ai verbindet sich nur über HTTPS',
            ],
            [
                'label' => 'Discovery (/.well-known/)',
                'ok' => $discovery === null ? null : ($discovery['status'] ?? '') === 'good',
                'message' => $discovery === null
                    ? 'noch nicht geprüft'
                    : wp_strip_all_tags((string) ($discovery['label'] ?? '')),
            ],
            [
                'label' => 'Endpunkt ohne Token',
                'ok' => $endpoint['ok'] ?? null,
                'message' => $endpoint['message'] ?? 'noch nicht geprüft',
            ],
        ];
    }
}

src/Admin/ConnectionTab.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

final class ConnectionTab implements AdminTab
{
    public function __construct(private ConnectionStatus $status)
    {
    }

    public function slug(): string
    {
        return 'verbindung';
    }

    public function label(): string
    {
        return __('Verbindung', 'wp-lit-mcp');
    }

    public function registerSettings(): void
    {
    }

    public function registerActions(): void
    {
    }

    public function render(): void
    {
        echo '<h2>' . esc_html__('MCP-Server', 'wp-lit-mcp') . '</h2>';
        printf(
            '<p><label for="lit-mcp-url">%s</label><br>'
                . '<input id="lit-mcp-url" type="text" class="large-text code" readonly value="%s"></p>',
            esc_html__('Server-URL für den Connector', 'wp-lit-mcp'),
            esc_attr(ConnectionStatus::serverUrl())
        );
        echo '<p>' . esc_html__(
            'In claude.ai unter Einstellungen → Connectors → „Add custom connector“ eintragen; die Anmeldung '
                . 'läuft danach über diese Website.',
            'wp-lit-mcp'
        ) . '</p>';

        echo '<h2>' . esc_html__('Status', 'wp-lit-mcp') . '</h2><table class="widefat striped"><tbody>';
        foreach ($this->status->checks(true) as $check) {
            $state = $check['ok'] === null ? '–' : ($check['ok'] ? '✔' : '✖');
            printf(
                '<tr><th scope="row">%s</th><td>%s</td><td>%s</td></tr>',
                esc_html($check['label']),
                esc_html($state),
                esc_html($check['message'])
            );
        }
        echo '</tbody></table>';
        echo '<p>' . esc_html__('Weitere Prüfungen unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp') . '</p>';
    }
}

src/Admin/AdminNotices.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

/**
 * Admin notices for administrators (Etappe-0 follow-up d). Uses only cached check
 * results so no HTTP request runs on ordinary admin page loads.
 */
final class AdminNotices
{
    public function __construct(private ConnectionStatus $status)
    {
    }

    public function register(): void
    {
        add_action('admin_notices', [$this, 'render']);
    }

    /** @return list<string> */
    public function messages(bool $onOwnPage): array
    {
        $messages = [];
        if (! $this->status->oauthEnabled()) {
            $messages[] = __(
                'LIT MCP: Der OAuth-Server ist deaktiviert (Filter wpmedia_mcp_oauth_server_enabled). '
                    . 'Claude kann sich nicht verbinden.',
                'wp-lit-mcp'
            );
        }

        $discovery = $this->status->discovery(false);
        if (is_array($discovery) && ($discovery['status'] ?? 'good') === 'critical') {
            $messages[] = __(
                'LIT MCP: Die OAuth-Discovery-Dokumente unter /.well-known/ sind nicht erreichbar. '
                    . 'Details unter Werkzeuge → Website-Zustand.',
                'wp-lit-mcp'
            );
        }

        if ($onOwnPage && ! $this->status->https()) {
            $messages[] = __(
                'LIT MCP: Die Website läuft ohne HTTPS; claude.ai verbindet sich nur mit HTTPS-Websites.',
                'wp-lit-mcp'
            );
        }

        return $messages;
    }

    public function render(): void
    {
        if (! current_user_can('manage_options')) {
            return;
        }

        $screen = function_exists('get_current_screen') ? get_current_screen() : null;
        $onOwnPage = is_object($screen) && $screen->id === 'settings_page_' . SettingsPage::SLUG;
        foreach ($this->messages($onOwnPage) as $message) {
            printf('<div class="notice notice-warning"><p>%s</p></div>', esc_html($message));
        }
    }
}

src/Admin/SiteHealth.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

/**
 * Site Health test for our MCP endpoint (401 + WWW-Authenticate without a token).
 * The discovery documents are covered by mcp-oauth's own Site Health test.
 */
final class SiteHealth
{
    public const TEST = 'lit_mcp_endpoint';

    public function __construct(private ConnectionStatus $status)
    {
    }

    public function register(): void
    {
        add_filter('site_status_tests', [$this, 'addTest']);
    }

    public function addTest(mixed $tests): mixed
    {
        if (! is_array($tests)) {
            return $tests;
        }

        $tests['direct'][self::TEST] = [
            'label' => __('LIT MCP Endpunkt', 'wp-lit-mcp'),
            'test' => [$this, 'run'],
        ];

        return $tests;
    }

    /** @return array<string, mixed> */
    public function run(): array
    {
        $oauth = $this->status->oauthEnabled();
        $endpoint = $oauth ? $this->status->endpoint(true) : null;
        $good = $oauth && ($endpoint['ok'] ?? false) === true;
        $detail = $oauth
            ? (string) ($endpoint['message'] ?? '')
            : __('Der OAuth-Server ist deaktiviert (Filter wpmedia_mcp_oauth_server_enabled).', 'wp-lit-mcp');

        return [
            'label' => $good
                ? __('Der LIT-MCP-Endpunkt ist erreichbar und verlangt OAuth', 'wp-lit-mcp')
                : __('Der LIT-MCP-Endpunkt ist nicht nutzbar', 'wp-lit-mcp'),
            'status' => $good ? 'good' : 'critical',
            'badge' => ['label' => 'LIT MCP', 'color' => $good ? 'blue' : 'red'],
            'description' => '<p>' . esc_html($detail) . '</p>',
            'actions' => sprintf(
                '<a href="%s">%s</a>',
                esc_url(SettingsPage::url('verbindung')),
                esc_html__('Einstellungen → LIT MCP', 'wp-lit-mcp')
            ),
            'test' => self::TEST,
        ];
    }
}

src/Plugin.php: Imports ergänzen

use LIT\MCP\Admin\AdminNotices;
use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\Admin\ConnectionTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Admin\SiteHealth;

eine Property private static ?SettingsPage $settingsPage = null; neben $pluginFile anlegen, die öffentliche Methode

    public static function settingsPage(): ?SettingsPage
    {
        return self::$settingsPage;
    }

ergänzen und in boot() direkt vor \WPMedia\MCP\OAuth\Bootstrap::instance(); einfügen:

        $status = new ConnectionStatus();
        (new SiteHealth($status))->register();
        self::$settingsPage = new SettingsPage([
            new ConnectionTab($status),
        ]);
        if (is_admin()) {
            self::$settingsPage->register();
            (new AdminNotices($status))->register();
        }
  • Step 4: Run – muss bestehen

Run: composer test && composer lint Expected: PASS.

  • Step 5: Commit
git add src/Admin src/Plugin.php tests/Unit/Admin
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: settings page with connection tab, admin notices and site health test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"

Task 9: Tab „Zugriff“ – freigegebene Rollen, aktive Verbindungen, Widerruf

Spec §8.4: erlaubte Rollen (Standard Administrator) und aktive Verbindungen je Benutzer mit Widerruf über die von mcp-oauth erzeugten Anwendungspasswörter. mcp-oauth legt pro Verbindung ein Anwendungspasswort an (Name = Client-Name, app_id = Client-ID) und speichert dazu das Benutzer-Meta mcp_refresh_jti_<uuid> (TokenEndpoint::REFRESH_JTI_META_PREFIX). Nur Anwendungspasswörter mit diesem Meta gelten als LIT-MCP-Verbindung; andere Anwendungspasswörter des Benutzers werden weder angezeigt noch widerrufen. Widerruf = WP_Application_Passwords::delete_application_password() – mcp-oauth räumt sein Meta dabei selbst auf (wp_delete_application_password) und lehnt das Token ab sofort ab.

Files:

  • Create: src/Admin/ConnectionRepository.php, src/Admin/AccessTab.php, tests/Stubs/WpApplicationPasswords.php
  • Modify: src/Plugin.php (boot()), tests/bootstrap.php
  • Test: tests/Unit/Admin/ConnectionRepositoryTest.php, tests/Unit/Admin/AccessTabTest.php

Interfaces:

  • Consumes: Access::OPTION, Access::allowedRoles(); SettingsPage::url(), AdminTab (Task 8); WPMedia\MCP\OAuth\Auth\TokenEndpoint::REFRESH_JTI_META_PREFIX.

  • Produces:

    • ConnectionRepository (nicht final): __construct(?\wpdb $db = null); all(): list<array{user_id: int, user_login: string, uuid: string, name: string, app_id: string, created: int, last_used: ?int}>; revoke(int $userId, string $uuid): bool (nur mcp-oauth-Verbindungen).
    • AccessTab(Access $access, ConnectionRepository $connections, ?callable $terminate = null) – slug zugriff; GROUP = 'lit_mcp_access', REVOKE_ACTION = 'lit_mcp_revoke'; static sanitizeRoles(mixed $input): list<string>; handleRevoke(): void (admin-post, Nonce lit_mcp_revoke_<uuid>, manage_options).
    • Test-Stub WP_Application_Passwords mit statischem Speicher $items[user_id][uuid].
  • Step 1: Stub

tests/Stubs/WpApplicationPasswords.php:

<?php

declare(strict_types=1);

if (! class_exists('WP_Application_Passwords')) {
    /**
     * In-memory stand-in for WordPress' application password store.
     */
    class WP_Application_Passwords
    {
        /** @var array<int, array<string, array<string, mixed>>> user ID => uuid => item */
        public static array $items = [];

        public static function get_user_application_password($user_id, $uuid)
        {
            return self::$items[(int) $user_id][(string) $uuid] ?? null;
        }

        public static function get_user_application_passwords($user_id)
        {
            return array_values(self::$items[(int) $user_id] ?? []);
        }

        public static function delete_application_password($user_id, $uuid)
        {
            if (! isset(self::$items[(int) $user_id][(string) $uuid])) {
                return new WP_Error('application_password_not_found', 'Not found.');
            }
            unset(self::$items[(int) $user_id][(string) $uuid]);

            return true;
        }
    }
}

tests/bootstrap.php: nach der Zeile mit Stubs/WpTerm.php ergänzen:

require_once __DIR__ . '/Stubs/WpApplicationPasswords.php';

(RequestContextTest::testClientNameFallsBackToTheClientIdClaim bleibt grün: der Stub kennt uuid-unknown nicht, also greift weiter der client_id-Fallback.)

  • Step 2: Failing Tests

tests/Unit/Admin/ConnectionRepositoryTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Admin;

use Brain\Monkey\Functions;
use LIT\MCP\Admin\ConnectionRepository;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Application_Passwords;

final class ConnectionRepositoryTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        WP_Application_Passwords::$items = [
            3 => [
                'uuid-1' => ['uuid' => 'uuid-1', 'name' => 'Claude', 'app_id' => 'https://claude.ai/x', 'created' => 1790000000, 'last_used' => null],
                'uuid-other' => ['uuid' => 'uuid-other', 'name' => 'Backup-Tool', 'app_id' => '', 'created' => 1, 'last_used' => null],
            ],
        ];
    }

    protected function tearDown(): void
    {
        WP_Application_Passwords::$items = [];
        parent::tearDown();
    }

    public function testListsOnlyMcpOauthConnections(): void
    {
        $db = Mockery::mock(\wpdb::class)->makePartial();
        $db->expects('get_results')
            ->with(Mockery::on(static fn (string $sql): bool => str_contains($sql, 'FROM wp_usermeta') && str_contains($sql, 'refresh')))
            ->andReturn([
                (object) ['user_id' => '3', 'meta_key' => 'mcp_refresh_jti_uuid-1'],
                (object) ['user_id' => '3', 'meta_key' => 'mcp_refresh_jti_uuid-gone'],
            ]);
        Functions\when('get_userdata')->justReturn((object) ['user_login' => 'redaktion']);

        $connections = (new ConnectionRepository($db))->all();

        self::assertCount(1, $connections);
        self::assertSame('redaktion', $connections[0]['user_login']);
        self::assertSame('Claude', $connections[0]['name']);
        self::assertSame('uuid-1', $connections[0]['uuid']);
        self::assertNull($connections[0]['last_used']);
    }

    public function testRevokesOnlyMcpOauthConnections(): void
    {
        Functions\when('get_user_meta')->alias(
            static fn (int $user, string $key): string => $key === 'mcp_refresh_jti_uuid-1' ? 'jti' : ''
        );
        $repository = new ConnectionRepository(Mockery::mock(\wpdb::class)->makePartial());

        self::assertFalse($repository->revoke(3, 'uuid-other'));
        self::assertArrayHasKey('uuid-other', WP_Application_Passwords::$items[3]);
        self::assertTrue($repository->revoke(3, 'uuid-1'));
        self::assertArrayNotHasKey('uuid-1', WP_Application_Passwords::$items[3]);
    }
}

tests/Unit/Admin/AccessTabTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Admin;

use Brain\Monkey\Functions;
use LIT\MCP\Access;
use LIT\MCP\Admin\AccessTab;
use LIT\MCP\Admin\ConnectionRepository;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class AccessTabTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Functions\stubEscapeFunctions();
        Functions\stubTranslationFunctions();
        $roles = Mockery::mock();
        $roles->roles = ['administrator' => [], 'editor' => []];
        $roles->allows('get_names')->andReturn(['administrator' => 'Administrator', 'editor' => 'Redakteur']);
        Functions\when('wp_roles')->justReturn($roles);
        Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
        Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
    }

    protected function tearDown(): void
    {
        $_POST = [];
        parent::tearDown();
    }

    public function testRegistersTheRolesOptionWithSanitizer(): void
    {
        Functions\expect('register_setting')->once()->with(
            'lit_mcp_access',
            'lit_mcp_allowed_roles',
            Mockery::on(static fn (array $args): bool => $args['sanitize_callback'] === [AccessTab::class, 'sanitizeRoles'])
        );

        (new AccessTab(new Access(), Mockery::mock(ConnectionRepository::class)))->registerSettings();
    }

    public function testSanitizeKeepsExistingRolesOnlyAndWarnsWhenEmpty(): void
    {
        self::assertSame(['editor'], AccessTab::sanitizeRoles(['editor', 'nope', '', 5]));

        Functions\expect('add_settings_error')->once();
        self::assertSame([], AccessTab::sanitizeRoles(null));
    }

    public function testRevokeChecksNonceAndRedirectsWithNotice(): void
    {
        $_POST = ['user_id' => '3', 'uuid' => 'uuid-1'];
        Functions\when('current_user_can')->justReturn(true);
        Functions\expect('check_admin_referer')->once()->with('lit_mcp_revoke_uuid-1');
        $connections = Mockery::mock(ConnectionRepository::class);
        $connections->expects('revoke')->with(3, 'uuid-1')->andReturn(true);
        Functions\expect('wp_safe_redirect')->once()->with(Mockery::on(
            static fn (string $url): bool => str_contains($url, 'tab=zugriff') && str_contains($url, 'lit_mcp_notice=revoked')
        ));
        $terminated = false;

        (new AccessTab(new Access(), $connections, static function () use (&$terminated): void {
            $terminated = true;
        }))->handleRevoke();

        self::assertTrue($terminated);
    }

    public function testRenderShowsRolesAndConnections(): void
    {
        Functions\when('get_option')->justReturn(['editor']);
        Functions\when('translate_user_role')->returnArg();
        Functions\when('checked')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' checked="checked"' : '');
        Functions\when('settings_fields')->justReturn(null);
        Functions\when('submit_button')->alias(static function (): void {
            echo '<button>Speichern</button>';
        });
        Functions\when('wp_nonce_field')->justReturn('');
        Functions\when('wp_date')->justReturn('25.09.2026 10:00');
        $connections = Mockery::mock(ConnectionRepository::class);
        $connections->allows('all')->andReturn([[
            'user_id' => 3,
            'user_login' => 'redaktion',
            'uuid' => 'uuid-1',
            'name' => 'Claude',
            'app_id' => 'https://claude.ai/x',
            'created' => 1790000000,
            'last_used' => null,
        ]]);

        ob_start();
        (new AccessTab(new Access(), $connections))->render();
        $html = (string) ob_get_clean();

        self::assertStringContainsString('value="editor" checked="checked"', $html);
        self::assertStringNotContainsString('value="administrator" checked', $html);
        self::assertStringContainsString('Claude', $html);
        self::assertStringContainsString('value="uuid-1"', $html);
    }
}
  • Step 3: Run – muss fehlschlagen

Run: composer test -- --filter 'ConnectionRepositoryTest|AccessTabTest' Expected: FAIL.

  • Step 4: Implementierung

src/Admin/ConnectionRepository.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

use WPMedia\MCP\OAuth\Auth\TokenEndpoint;

/**
 * OAuth connections created by mcp-oauth: application passwords that carry the
 * refresh-token marker meta. Other application passwords are never touched.
 * Not final so the access tab can be tested with a mock.
 */
class ConnectionRepository
{
    public function __construct(private ?\wpdb $db = null)
    {
    }

    /**
     * @return list<array<string, mixed>> Keys: user_id, user_login, uuid, name, app_id, created, last_used.
     */
    public function all(): array
    {
        $wpdb = $this->db ?? $GLOBALS['wpdb'];
        $prefix = TokenEndpoint::REFRESH_JTI_META_PREFIX;
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT user_id, meta_key FROM {$wpdb->usermeta} WHERE meta_key LIKE %s ORDER BY user_id, umeta_id",
            $wpdb->esc_like($prefix) . '%'
        ));

        $connections = [];
        foreach ((array) $rows as $row) {
            $userId = (int) $row->user_id;
            $uuid = substr((string) $row->meta_key, strlen($prefix));
            $item = \WP_Application_Passwords::get_user_application_password($userId, $uuid);
            if (! is_array($item)) {
                continue;
            }

            $user = get_userdata($userId);
            $connections[] = [
                'user_id' => $userId,
                'user_login' => is_object($user) ? (string) $user->user_login : '#' . $userId,
                'uuid' => $uuid,
                'name' => (string) ($item['name'] ?? ''),
                'app_id' => (string) ($item['app_id'] ?? ''),
                'created' => (int) ($item['created'] ?? 0),
                'last_used' => isset($item['last_used']) ? (int) $item['last_used'] : null,
            ];
        }

        return $connections;
    }

    public function revoke(int $userId, string $uuid): bool
    {
        if ($userId <= 0 || $uuid === '') {
            return false;
        }

        $marker = (string) get_user_meta($userId, TokenEndpoint::REFRESH_JTI_META_PREFIX . $uuid, true);
        if ($marker === '') {
            return false;
        }

        return \WP_Application_Passwords::delete_application_password($userId, $uuid) === true;
    }
}

src/Admin/AccessTab.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

use LIT\MCP\Access;

final class AccessTab implements AdminTab
{
    public const GROUP = 'lit_mcp_access';
    public const REVOKE_ACTION = 'lit_mcp_revoke';

    /** @var callable(): void */
    private $terminate;

    public function __construct(
        private Access $access,
        private ConnectionRepository $connections,
        ?callable $terminate = null
    ) {
        $this->terminate = $terminate ?? static function (): void {
            exit;
        };
    }

    public function slug(): string
    {
        return 'zugriff';
    }

    public function label(): string
    {
        return __('Zugriff', 'wp-lit-mcp');
    }

    public function registerSettings(): void
    {
        register_setting(self::GROUP, Access::OPTION, [
            'type' => 'array',
            'sanitize_callback' => [self::class, 'sanitizeRoles'],
            'default' => ['administrator'],
        ]);
    }

    public function registerActions(): void
    {
        add_action('admin_post_' . self::REVOKE_ACTION, [$this, 'handleRevoke']);
    }

    /** @return list<string> */
    public static function sanitizeRoles(mixed $input): array
    {
        $existing = array_keys((array) wp_roles()->roles);
        $roles = array_values(array_unique(array_intersect(
            array_filter(is_array($input) ? $input : [], 'is_string'),
            $existing
        )));

        if ($roles === []) {
            add_settings_error(
                Access::OPTION,
                'lit_mcp_no_roles',
                __('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp'),
                'warning'
            );
        }

        return $roles;
    }

    public function render(): void
    {
        $allowed = $this->access->allowedRoles();

        echo '<h2>' . esc_html__('Freigegebene Rollen', 'wp-lit-mcp') . '</h2>';
        echo '<p>' . esc_html__(
            'Nur Benutzer dieser Rollen können Tools nutzen; zusätzlich gelten ihre normalen WordPress-Rechte.',
            'wp-lit-mcp'
        ) . '</p>';
        echo '<form method="post" action="options.php">';
        settings_fields(self::GROUP);
        printf('<input type="hidden" name="%s[]" value="">', esc_attr(Access::OPTION));
        foreach (wp_roles()->get_names() as $role => $name) {
            printf(
                '<label><input type="checkbox" name="%s[]" value="%s"%s> %s</label><br>',
                esc_attr(Access::OPTION),
                esc_attr((string) $role),
                checked(in_array($role, $allowed, true), true, false),
                esc_html(translate_user_role((string) $name))
            );
        }
        submit_button();
        echo '</form>';

        $this->renderConnections();
    }

    public function handleRevoke(): void
    {
        if (! current_user_can('manage_options')) {
            wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]);
        }

        // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified right below.
        $uuid = isset($_POST['uuid']) ? sanitize_text_field(wp_unslash((string) $_POST['uuid'])) : '';
        $userId = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;
        // phpcs:enable WordPress.Security.NonceVerification.Missing
        check_admin_referer(self::REVOKE_ACTION . '_' . $uuid);

        $revoked = $this->connections->revoke($userId, $uuid);
        $notice = $revoked ? 'revoked' : 'revoke_failed';
        wp_safe_redirect(SettingsPage::url($this->slug(), ['lit_mcp_notice' => $notice]));
        ($this->terminate)();
    }

    private function renderConnections(): void
    {
        echo '<h2>' . esc_html__('Aktive Verbindungen', 'wp-lit-mcp') . '</h2>';
        $connections = $this->connections->all();
        if ($connections === []) {
            echo '<p>' . esc_html__('Keine aktiven Verbindungen.', 'wp-lit-mcp') . '</p>';
            return;
        }

        echo '<table class="widefat striped"><thead><tr>';
        $headings = [
            __('Benutzer', 'wp-lit-mcp'),
            __('Client', 'wp-lit-mcp'),
            __('Erstellt', 'wp-lit-mcp'),
            __('Zuletzt genutzt', 'wp-lit-mcp'),
            '',
        ];
        foreach ($headings as $heading) {
            echo '<th>' . esc_html($heading) . '</th>';
        }
        echo '</tr></thead><tbody>';

        foreach ($connections as $connection) {
            echo '<tr>';
            echo '<td>' . esc_html($connection['user_login']) . '</td>';
            printf('<td>%s<br><code>%s</code></td>', esc_html($connection['name']), esc_html($connection['app_id']));
            echo '<td>' . esc_html((string) wp_date('Y-m-d H:i', $connection['created'])) . '</td>';
            $lastUsed = $connection['last_used'] === null
                ? '–'
                : (string) wp_date('Y-m-d H:i', $connection['last_used']);
            echo '<td>' . esc_html($lastUsed) . '</td>';
            echo '<td>';
            printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
            printf('<input type="hidden" name="action" value="%s">', esc_attr(self::REVOKE_ACTION));
            printf('<input type="hidden" name="user_id" value="%d">', $connection['user_id']);
            printf('<input type="hidden" name="uuid" value="%s">', esc_attr($connection['uuid']));
            wp_nonce_field(self::REVOKE_ACTION . '_' . $connection['uuid']);
            submit_button(__('Widerrufen', 'wp-lit-mcp'), 'delete small', 'submit', false);
            echo '</form></td></tr>';
        }

        echo '</tbody></table>';
    }
}

src/Plugin.php: Imports use LIT\MCP\Admin\AccessTab; und use LIT\MCP\Admin\ConnectionRepository; ergänzen; in boot() die Zeile (new Access())->register(); ersetzen durch

        $access = new Access();
        $access->register();

und die Tab-Liste ersetzen durch

        self::$settingsPage = new SettingsPage([
            new ConnectionTab($status),
            new AccessTab($access, new ConnectionRepository()),
        ]);
  • Step 5: Run – muss bestehen

Run: composer test && composer lint Expected: PASS (lange Zeilen im Tabellenkopf umbrechen).

  • Step 6: Commit
git add src/Admin src/Plugin.php tests/Stubs/WpApplicationPasswords.php tests/bootstrap.php tests/Unit/Admin
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: access tab with allowed roles and connection revocation

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"

Task 10: Tabs „Tools“ (Exposition je Ability) und „Limits“

Spec §8.4: „direkt / indirekt / deaktiviert je Ability“ und „Upload-Größe, Protokoll-Aufbewahrung, Anzahl Snapshots“. Die Auswahl „Standard“ speichert nichts – so wirkt ein geänderter Standard einer späteren Version automatisch. Unmögliche Kombinationen (lit/media-preview indirekt, lit/guide deaktiviert) werden gar nicht angeboten und von ExposurePolicy::sanitize() zusätzlich verworfen. Für das Divi-Code-Modul gibt es keine Einstellung.

Files:

  • Create: src/Admin/ToolsTab.php, src/Admin/LimitsTab.php
  • Modify: src/Plugin.php (Tab-Liste)
  • Test: tests/Unit/Admin/ToolsTabTest.php, tests/Unit/Admin/LimitsTabTest.php

Interfaces:

  • Consumes: ExposurePolicy::OPTION/MODES/mode()/defaultMode()/isAllowed()/sanitize() (Task 6), IntegrationRegistry::abilities(), Limits::OPTION/DEFAULTS/all()/sanitize() (1a), AdminTab.

  • Produces: ToolsTab(IntegrationRegistry $registry, ExposurePolicy $policy) – slug tools, GROUP = 'lit_mcp_tools'; LimitsTab() – slug limits, GROUP = 'lit_mcp_limits'.

  • Step 1: Failing Tests

tests/Unit/Admin/ToolsTabTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Admin;

use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Admin\ToolsTab;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class ToolsTabTest extends TestCase
{
    private function registry(): IntegrationRegistry
    {
        $abilities = [];
        foreach ([['lit/guide', true], ['lit/media-preview', true], ['lit/menu-list', false]] as [$name, $direct]) {
            $ability = Mockery::mock(Ability::class);
            $ability->allows('name')->andReturn($name);
            $ability->allows('label')->andReturn('Label ' . $name);
            $ability->allows('isDirect')->andReturn($direct);
            $abilities[] = $ability;
        }
        $integration = Mockery::mock(Integration::class);
        $integration->allows('isAvailable')->andReturn(true);
        $integration->allows('abilities')->andReturn($abilities);

        return new IntegrationRegistry($integration);
    }

    public function testRegistersTheExposureOptionWithThePolicySanitizer(): void
    {
        Functions\expect('register_setting')->once()->with(
            'lit_mcp_tools',
            'lit_mcp_exposure',
            Mockery::on(static fn (array $args): bool => $args['sanitize_callback'] === [ExposurePolicy::class, 'sanitize'])
        );

        (new ToolsTab($this->registry(), new ExposurePolicy()))->registerSettings();
    }

    public function testRendersOneSelectPerAbilityWithOnlyAllowedModes(): void
    {
        Functions\stubEscapeFunctions();
        Functions\stubTranslationFunctions();
        Functions\when('settings_fields')->justReturn(null);
        Functions\when('submit_button')->justReturn(null);
        Functions\when('selected')->alias(static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' selected="selected"' : '');

        ob_start();
        (new ToolsTab($this->registry(), new ExposurePolicy(['lit/menu-list' => 'disabled'])))->render();
        $html = (string) ob_get_clean();

        $selects = array_slice(explode('<select', $html), 1);
        self::assertCount(3, $selects);
        [$guide, $preview, $menu] = $selects;
        self::assertStringContainsString('lit_mcp_exposure[lit/guide]', $guide);
        self::assertStringNotContainsString('value="disabled"', $guide);
        self::assertStringNotContainsString('value="indirect"', $preview);
        self::assertStringContainsString('value="disabled" selected="selected"', $menu);
        self::assertStringContainsString('value="" selected="selected"', $guide);
    }
}

tests/Unit/Admin/LimitsTabTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Admin;

use Brain\Monkey\Functions;
use LIT\MCP\Admin\LimitsTab;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class LimitsTabTest extends TestCase
{
    public function testRegistersTheLimitsOption(): void
    {
        Functions\expect('register_setting')->once()->with(
            'lit_mcp_limits',
            'lit_mcp_limits',
            Mockery::on(static fn (array $args): bool => $args['sanitize_callback'] === [Limits::class, 'sanitize']
                && $args['default'] === Limits::DEFAULTS)
        );

        (new LimitsTab())->registerSettings();
    }

    public function testRendersThreeNumberFieldsWithCurrentValues(): void
    {
        Functions\stubEscapeFunctions();
        Functions\stubTranslationFunctions();
        Functions\when('settings_fields')->justReturn(null);
        Functions\when('submit_button')->justReturn(null);
        Functions\when('get_option')->justReturn(['upload_mb' => 8]);

        ob_start();
        (new LimitsTab())->render();
        $html = (string) ob_get_clean();

        self::assertSame(3, substr_count($html, 'type="number"'));
        self::assertStringContainsString('name="lit_mcp_limits[upload_mb]" value="8"', $html);
        self::assertStringContainsString('name="lit_mcp_limits[log_retention_days]" value="90"', $html);
    }
}
  • Step 2: Run – muss fehlschlagen

Run: composer test -- --filter 'ToolsTabTest|LimitsTabTest' Expected: FAIL.

  • Step 3: Implementierung

src/Admin/ToolsTab.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Integrations\IntegrationRegistry;

final class ToolsTab implements AdminTab
{
    public const GROUP = 'lit_mcp_tools';

    public function __construct(private IntegrationRegistry $registry, private ExposurePolicy $policy)
    {
    }

    public function slug(): string
    {
        return 'tools';
    }

    public function label(): string
    {
        return __('Tools', 'wp-lit-mcp');
    }

    public function registerSettings(): void
    {
        register_setting(self::GROUP, ExposurePolicy::OPTION, [
            'type' => 'array',
            'sanitize_callback' => [ExposurePolicy::class, 'sanitize'],
            'default' => [],
        ]);
    }

    public function registerActions(): void
    {
    }

    public function render(): void
    {
        echo '<p>' . esc_html__(
            'Direkt: sofort als eigenes Tool sichtbar. Indirekt: nur über die Meta-Tools (discover/execute). '
                . 'Deaktiviert: gar nicht verfügbar. Änderungen gelten für neue Sitzungen; in claude.ai ggf. den '
                . 'Connector neu verbinden. Das Divi-Code-Modul ist immer erlaubt (nur nachrangig).',
            'wp-lit-mcp'
        ) . '</p>';
        echo '<form method="post" action="options.php">';
        settings_fields(self::GROUP);
        echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Tool', 'wp-lit-mcp') . '</th><th>'
            . esc_html__('Einstellung', 'wp-lit-mcp') . '</th></tr></thead><tbody>';

        foreach ($this->registry->abilities() as $ability) {
            $name = $ability->name();
            $current = $this->policy->mode($ability);
            $isDefault = $current === $this->policy->defaultMode($ability);
            printf(
                '<tr><td><strong>%s</strong><br><code>%s</code></td><td><select name="%s[%s]">',
                esc_html($ability->label()),
                esc_html($name),
                esc_attr(ExposurePolicy::OPTION),
                esc_attr($name)
            );
            printf(
                '<option value=""%s>%s</option>',
                selected($isDefault, true, false),
                esc_html(sprintf(
                    /* translators: %s: default mode */
                    __('Standard (%s)', 'wp-lit-mcp'),
                    $this->modeLabel($this->policy->defaultMode($ability))
                ))
            );
            foreach (ExposurePolicy::MODES as $mode) {
                if (! ExposurePolicy::isAllowed($name, $mode)) {
                    continue;
                }
                printf(
                    '<option value="%s"%s>%s</option>',
                    esc_attr($mode),
                    selected(! $isDefault && $current === $mode, true, false),
                    esc_html($this->modeLabel($mode))
                );
            }
            echo '</select></td></tr>';
        }

        echo '</tbody></table>';
        submit_button();
        echo '</form>';
    }

    private function modeLabel(string $mode): string
    {
        return match ($mode) {
            ExposurePolicy::DIRECT => __('direkt', 'wp-lit-mcp'),
            ExposurePolicy::INDIRECT => __('indirekt', 'wp-lit-mcp'),
            default => __('deaktiviert', 'wp-lit-mcp'),
        };
    }
}

src/Admin/LimitsTab.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

use LIT\MCP\Limits;

final class LimitsTab implements AdminTab
{
    public const GROUP = 'lit_mcp_limits';

    public function slug(): string
    {
        return 'limits';
    }

    public function label(): string
    {
        return __('Limits', 'wp-lit-mcp');
    }

    public function registerSettings(): void
    {
        register_setting(self::GROUP, Limits::OPTION, [
            'type' => 'array',
            'sanitize_callback' => [Limits::class, 'sanitize'],
            'default' => Limits::DEFAULTS,
        ]);
    }

    public function registerActions(): void
    {
    }

    public function render(): void
    {
        $values = (new Limits())->all();
        $fields = [
            'upload_mb' => [__('Maximale Upload-/Download-Größe (MB)', 'wp-lit-mcp'), 1, 100],
            'log_retention_days' => [__('Protokoll aufbewahren (Tage)', 'wp-lit-mcp'), 1, 3650],
            'snapshot_count' => [__('Anzahl Designsystem-Snapshots (ab Etappe 4)', 'wp-lit-mcp'), 1, 100],
        ];

        echo '<form method="post" action="options.php">';
        settings_fields(self::GROUP);
        echo '<table class="form-table"><tbody>';
        foreach ($fields as $key => [$label, $min, $max]) {
            printf(
                '<tr><th scope="row"><label for="lit-mcp-%1$s">%2$s</label></th><td>'
                    . '<input id="lit-mcp-%1$s" type="number" min="%3$d" max="%4$d" name="%5$s[%1$s]" value="%6$d">'
                    . '</td></tr>',
                esc_attr($key),
                esc_html($label),
                $min,
                $max,
                esc_attr(Limits::OPTION),
                $values[$key]
            );
        }
        echo '</tbody></table>';
        submit_button();
        echo '</form>';
    }
}

src/Plugin.php: Imports use LIT\MCP\Admin\LimitsTab; und use LIT\MCP\Admin\ToolsTab; ergänzen und die Tab-Liste ersetzen durch

        self::$settingsPage = new SettingsPage([
            new ConnectionTab($status),
            new AccessTab($access, new ConnectionRepository()),
            new ToolsTab($registry, $policy),
            new LimitsTab(),
        ]);
  • Step 4: Run – muss bestehen

Run: composer test && composer lint Expected: PASS.

  • Step 5: Commit
git add src/Admin src/Plugin.php tests/Unit/Admin
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: tools and limits settings tabs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"

Spec §8.3: Admin-Ansicht (WP_List_Table), filterbar, Links zu Revision/Wiederherstellen, Button „Protokoll leeren“. Der Revisions-Link führt auf WordPress' Revisionsvergleich (revision.php?revision=<ID>), dort gibt es „Diese Revision wiederherstellen“.

Files:

  • Create: src/Admin/LogTab.php, src/Admin/LogListTable.php
  • Modify: src/ChangeLog/ChangeLog.php (query(), abilities(), clear()), src/Plugin.php (Tab-Liste)
  • Test: tests/Unit/ChangeLog/ChangeLogTest.php (Tests ergänzen), tests/Unit/Admin/LogTabTest.php

Interfaces:

  • Consumes: ChangeLog::tableName()/db() (1a), Limits::logRetentionDays(), SettingsPage::url()/SLUG, AdminTab.

  • Produces:

    • ChangeLog::query(array{ability?: string, user_id?: int} $filters, int $limit, int $offset): array{rows: list<array<string, mixed>>, total: int} (neueste zuerst); ChangeLog::abilities(): list<string>; ChangeLog::clear(): void.
    • LogTab(ChangeLog $log, Limits $limits, ?callable $terminate = null) – slug protokoll, CLEAR_ACTION = 'lit_mcp_clear_log'; filters(): array{ability: string, user_id: int}; handleClear(): void.
    • LogListTable extends \WP_List_Table (final): __construct(ChangeLog $log, array $filters); Spalten Zeit (UTC), Benutzer, Client, Tool, Objekt (Bearbeiten-Link bei Beiträgen), Zusammenfassung, Revision (Link), Ergebnis; 50 Einträge je Seite.
  • Step 1: Failing Tests

In tests/Unit/ChangeLog/ChangeLogTest.php ergänzen:

    public function testQueryFiltersAndPaginatesNewestFirst(): void
    {
        $db = Mockery::mock(\wpdb::class)->makePartial();
        $db->expects('get_var')
            ->with(Mockery::on(static fn (string $sql): bool => $sql === "SELECT COUNT(*) FROM wp_lit_mcp_log WHERE 1 = 1 AND ability = 'lit/content-update' AND user_id = 3"))
            ->andReturn('12');
        $db->expects('get_results')
            ->with(
                Mockery::on(static fn (string $sql): bool => str_ends_with($sql, 'ORDER BY id DESC LIMIT 50 OFFSET 50')),
                'ARRAY_A'
            )
            ->andReturn([['id' => '7', 'ability' => 'lit/content-update']]);

        $result = (new ChangeLog($db))->query(['ability' => 'lit/content-update', 'user_id' => 3], 50, 50);

        self::assertSame(12, $result['total']);
        self::assertSame('7', $result['rows'][0]['id']);
    }

    public function testAbilitiesAndClear(): void
    {
        $db = Mockery::mock(\wpdb::class)->makePartial();
        $db->expects('get_col')->with('SELECT DISTINCT ability FROM wp_lit_mcp_log ORDER BY ability')->andReturn(['lit/a']);
        $db->expects('query')->with('DELETE FROM wp_lit_mcp_log')->andReturn(4);
        $log = new ChangeLog($db);

        self::assertSame(['lit/a'], $log->abilities());
        $log->clear();
    }

tests/Unit/Admin/LogTabTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Admin;

use Brain\Monkey\Functions;
use LIT\MCP\Admin\LogTab;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class LogTabTest extends TestCase
{
    protected function tearDown(): void
    {
        unset($_GET['ability'], $_GET['user_id']);
        parent::tearDown();
    }

    public function testFiltersAreReadSanitized(): void
    {
        $tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
        $_GET['ability'] = 'lit/content-update';
        $_GET['user_id'] = '3x';

        self::assertSame(['ability' => 'lit/content-update', 'user_id' => 3], $tab->filters());

        $_GET['ability'] = 'lit/x"><script>';
        self::assertSame('', $tab->filters()['ability']);
    }

    public function testClearNeedsNonceAndRights(): void
    {
        Functions\stubTranslationFunctions();
        Functions\when('current_user_can')->justReturn(true);
        Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
        Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
        Functions\expect('check_admin_referer')->once()->with('lit_mcp_clear_log');
        Functions\expect('wp_safe_redirect')->once()->with(Mockery::on(
            static fn (string $url): bool => str_contains($url, 'tab=protokoll') && str_contains($url, 'lit_mcp_notice=cleared')
        ));
        $log = Mockery::mock(ChangeLog::class);
        $log->expects('clear')->once();
        $terminated = false;

        (new LogTab($log, new Limits(), static function () use (&$terminated): void {
            $terminated = true;
        }))->handleClear();

        self::assertTrue($terminated);
    }

    public function testRegistersTheClearAction(): void
    {
        $tab = new LogTab(Mockery::mock(ChangeLog::class), new Limits());
        Functions\expect('add_action')->once()->with('admin_post_lit_mcp_clear_log', [$tab, 'handleClear']);

        $tab->registerActions();
    }
}
  • Step 2: Run – muss fehlschlagen

Run: composer test -- --filter 'ChangeLogTest|LogTabTest' Expected: FAIL.

  • Step 3: Implementierung

In src/ChangeLog/ChangeLog.php nach purgeOlderThan() ergänzen:

    /**
     * @param array{ability?: string, user_id?: int} $filters
     * @return array{rows: list<array<string, mixed>>, total: int}
     */
    public function query(array $filters, int $limit, int $offset): array
    {
        $wpdb = $this->db();
        $table = $this->tableName();
        $where = ['1 = %d'];
        $args = [1];
        if (($filters['ability'] ?? '') !== '') {
            $where[] = 'ability = %s';
            $args[] = (string) $filters['ability'];
        }
        if ((int) ($filters['user_id'] ?? 0) > 0) {
            $where[] = 'user_id = %d';
            $args[] = (int) $filters['user_id'];
        }
        $whereSql = implode(' AND ', $where);

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table; only placeholders.
        $total = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$whereSql}", ...$args));
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$table} WHERE {$whereSql} ORDER BY id DESC LIMIT %d OFFSET %d",
                ...array_merge($args, [$limit, $offset])
            ),
            'ARRAY_A'
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        return ['rows' => is_array($rows) ? array_values($rows) : [], 'total' => $total];
    }

    /** @return list<string> */
    public function abilities(): array
    {
        $wpdb = $this->db();
        $table = $this->tableName();

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
        $abilities = (array) $wpdb->get_col("SELECT DISTINCT ability FROM {$table} ORDER BY ability");

        return array_values(array_map('strval', $abilities));
    }

    public function clear(): void
    {
        $wpdb = $this->db();
        $table = $this->tableName();

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
        $wpdb->query("DELETE FROM {$table}");
    }

src/Admin/LogListTable.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

use LIT\MCP\ChangeLog\ChangeLog;

// phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps -- method names are defined by WP_List_Table.

/**
 * List of change log entries. WP_List_Table must be loaded before this file
 * (LogTab::render() takes care of that).
 */
final class LogListTable extends \WP_List_Table
{
    private const PER_PAGE = 50;

    /** @param array{ability: string, user_id: int} $filters */
    public function __construct(private ChangeLog $log, private array $filters)
    {
        parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
    }

    /** @return array<string, string> */
    public function get_columns(): array
    {
        return [
            'created_at' => __('Zeit (UTC)', 'wp-lit-mcp'),
            'user' => __('Benutzer', 'wp-lit-mcp'),
            'client' => __('Client', 'wp-lit-mcp'),
            'ability' => __('Tool', 'wp-lit-mcp'),
            'object' => __('Objekt', 'wp-lit-mcp'),
            'summary' => __('Zusammenfassung', 'wp-lit-mcp'),
            'revision' => __('Revision', 'wp-lit-mcp'),
            'result' => __('Ergebnis', 'wp-lit-mcp'),
        ];
    }

    public function prepare_items(): void
    {
        $page = max(1, (int) $this->get_pagenum());
        $data = $this->log->query($this->filters, self::PER_PAGE, ($page - 1) * self::PER_PAGE);
        $this->items = $data['rows'];
        $this->_column_headers = [$this->get_columns(), [], []];
        $this->set_pagination_args(['total_items' => $data['total'], 'per_page' => self::PER_PAGE]);
    }

    public function no_items(): void
    {
        esc_html_e('Keine Einträge.', 'wp-lit-mcp');
    }

    /**
     * @param array<string, mixed> $item
     * @param string               $column_name
     */
    protected function column_default($item, $column_name): string
    {
        return match ($column_name) {
            'user' => $this->userName((int) ($item['user_id'] ?? 0)),
            'object' => $this->objectLink($item),
            'revision' => $this->revisionLink((int) ($item['revision_id'] ?? 0)),
            default => esc_html((string) ($item[$column_name] ?? '')),
        };
    }

    private function userName(int $userId): string
    {
        $user = get_userdata($userId);

        return esc_html(is_object($user) ? (string) $user->display_name : '#' . $userId);
    }

    /** @param array<string, mixed> $item */
    private function objectLink(array $item): string
    {
        $label = trim(sprintf('%s %s', $item['object_type'] ?? '', $item['object_id'] ?? ''));
        $objectId = (string) ($item['object_id'] ?? '');
        $url = ctype_digit($objectId) && get_post((int) $objectId)
            ? (string) get_edit_post_link((int) $objectId, 'raw')
            : '';

        return $url !== '' ? sprintf('<a href="%s">%s</a>', esc_url($url), esc_html($label)) : esc_html($label);
    }

    private function revisionLink(int $revisionId): string
    {
        if ($revisionId <= 0) {
            return '–';
        }

        return sprintf(
            '<a href="%s">%s</a>',
            esc_url(admin_url('revision.php?revision=' . $revisionId)),
            esc_html__('ansehen / wiederherstellen', 'wp-lit-mcp')
        );
    }
}

src/Admin/LogTab.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Admin;

use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Limits;

final class LogTab implements AdminTab
{
    public const CLEAR_ACTION = 'lit_mcp_clear_log';

    /** @var callable(): void */
    private $terminate;

    public function __construct(private ChangeLog $log, private Limits $limits, ?callable $terminate = null)
    {
        $this->terminate = $terminate ?? static function (): void {
            exit;
        };
    }

    public function slug(): string
    {
        return 'protokoll';
    }

    public function label(): string
    {
        return __('Protokoll', 'wp-lit-mcp');
    }

    public function registerSettings(): void
    {
    }

    public function registerActions(): void
    {
        add_action('admin_post_' . self::CLEAR_ACTION, [$this, 'handleClear']);
    }

    /** @return array{ability: string, user_id: int} */
    public function filters(): array
    {
        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters.
        $ability = isset($_GET['ability']) ? (string) wp_unslash((string) $_GET['ability']) : '';
        $userId = isset($_GET['user_id']) ? absint($_GET['user_id']) : 0;
        // phpcs:enable WordPress.Security.NonceVerification.Recommended

        return [
            'ability' => preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $ability) === 1 ? $ability : '',
            'user_id' => $userId,
        ];
    }

    public function render(): void
    {
        if (! class_exists('WP_List_Table')) {
            require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
        }

        $filters = $this->filters();
        printf(
            '<p>%s</p>',
            esc_html(sprintf(
                /* translators: %d: number of days */
                __(
                    'Einträge werden nach %d Tagen automatisch gelöscht (einstellbar im Tab „Limits“). '
                        . 'Das Protokoll enthält nur Verweise, keine Inhalte.',
                    'wp-lit-mcp'
                ),
                $this->limits->logRetentionDays()
            ))
        );

        echo '<form method="get">';
        printf('<input type="hidden" name="page" value="%s">', esc_attr(SettingsPage::SLUG));
        printf('<input type="hidden" name="tab" value="%s">', esc_attr($this->slug()));
        echo '<select name="ability"><option value="">' . esc_html__('Alle Tools', 'wp-lit-mcp') . '</option>';
        foreach ($this->log->abilities() as $ability) {
            printf(
                '<option value="%1$s"%2$s>%1$s</option>',
                esc_attr($ability),
                selected($filters['ability'], $ability, false)
            );
        }
        echo '</select> ';
        wp_dropdown_users([
            'name' => 'user_id',
            'show_option_all' => __('Alle Benutzer', 'wp-lit-mcp'),
            'selected' => $filters['user_id'],
        ]);
        submit_button(__('Filtern', 'wp-lit-mcp'), 'secondary', '', false);
        echo '</form>';

        $table = new LogListTable($this->log, $filters);
        $table->prepare_items();
        $table->display();

        printf('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
        printf('<input type="hidden" name="action" value="%s">', esc_attr(self::CLEAR_ACTION));
        wp_nonce_field(self::CLEAR_ACTION);
        submit_button(__('Protokoll leeren', 'wp-lit-mcp'), 'delete', 'submit', false);
        echo '</form>';
    }

    public function handleClear(): void
    {
        if (! current_user_can('manage_options')) {
            wp_die(esc_html__('Keine Berechtigung.', 'wp-lit-mcp'), '', ['response' => 403]);
        }

        check_admin_referer(self::CLEAR_ACTION);
        $this->log->clear();
        wp_safe_redirect(SettingsPage::url($this->slug(), ['lit_mcp_notice' => 'cleared']));
        ($this->terminate)();
    }
}

src/Plugin.php: Import use LIT\MCP\Admin\LogTab; ergänzen und die Tab-Liste durch die Endfassung ersetzen:

        self::$settingsPage = new SettingsPage([
            new ConnectionTab($status),
            new AccessTab($access, new ConnectionRepository()),
            new ToolsTab($registry, $policy),
            new LogTab($changeLog, $limits),
            new LimitsTab(),
        ]);
  • Step 4: Run – muss bestehen

Run: composer test && composer lint Expected: PASS (lange Zeilen umbrechen).

  • Step 5: Commit
git add src/Admin src/ChangeLog/ChangeLog.php src/Plugin.php tests/Unit/Admin tests/Unit/ChangeLog/ChangeLogTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: change log tab with filters, revision links and clear button

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"

Task 12: Aufräumen – Deaktivierung und Deinstallation

Aus Etappe 0 offen (c). Deaktivieren entfernt den Cron-Job und den Prüf-Transient und verwirft die gespeicherten Rewrite-Regeln, damit WordPress sie beim nächsten Aufruf ohne die /oauth/…- und /.well-known/…-Regeln des nun inaktiven Plugins neu erzeugt (ein flush_rewrite_rules() während der Deaktivierung würde sie wieder mitschreiben, weil das Plugin in diesem Request noch geladen ist). Aktivieren stößt über mcp-oauths öffentliche API Bootstrap::schedule_rewrite_flush() das Neuschreiben seiner Regeln an. Löschen entfernt alle eigenen Daten, auf Multisite je Website – und bewusst nicht die Daten von mcp-oauth (siehe Global Constraints; dokumentiert in readme.txt, Task 13).

Files:

  • Create: uninstall.php, tests/Unit/UninstallTest.php
  • Modify: src/Lifecycle.php (komplett ersetzen), phpcs.xml.dist (Datei ergänzen), tests/Unit/LifecycleTest.php (komplett ersetzen)

Interfaces:

  • Consumes: ChangeLog, ChangeLogInstaller::VERSION_OPTION, ChangeLogCleanup::HOOK, Limits::OPTION, Access::OPTION, ExposurePolicy::OPTION, ConnectionStatus::ENDPOINT_TRANSIENT; WPMedia\MCP\OAuth\Bootstrap::schedule_rewrite_flush().

  • Produces: Lifecycle::activate() (zusätzlich Rewrite-Flush von mcp-oauth anstoßen), Lifecycle::deactivate() (Cron, Transient, rewrite_rules); uninstall.php (Optionen lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version, Transient lit_mcp_endpoint_check, Cron lit_mcp_log_cleanup, Tabelle {prefix}lit_mcp_log).

  • Step 1: Failing Tests

tests/Unit/LifecycleTest.php (komplett ersetzen):

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit;

use Brain\Monkey\Functions;
use LIT\MCP\Lifecycle;
use Mockery;

final class LifecycleTest extends TestCase
{
    protected function tearDown(): void
    {
        unset($GLOBALS['wpdb']);
        parent::tearDown();
    }

    public function testActivateInstallsTableSchedulesCleanupAndRequestsOauthRewriteFlush(): void
    {
        $GLOBALS['wpdb'] = Mockery::mock(\wpdb::class)->makePartial();
        Functions\expect('dbDelta')->once();
        Functions\expect('update_option')->once()->with('lit_mcp_db_version', '1');
        Functions\when('wp_next_scheduled')->justReturn(false);
        Functions\expect('wp_schedule_event')->once();
        Functions\expect('delete_option')->once()->with('wpmedia_mcp_oauth_rewrite_version');

        Lifecycle::activate();
    }

    public function testDeactivateRemovesCronTransientAndStoredRewriteRules(): void
    {
        Functions\expect('wp_clear_scheduled_hook')->once()->with('lit_mcp_log_cleanup');
        Functions\expect('delete_transient')->once()->with('lit_mcp_endpoint_check');
        Functions\expect('delete_option')->once()->with('rewrite_rules');

        Lifecycle::deactivate();
    }
}

tests/Unit/UninstallTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit;

use Brain\Monkey\Functions;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Access;
use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
use LIT\MCP\Limits;
use Mockery;

final class UninstallTest extends TestCase
{
    protected function tearDown(): void
    {
        unset($GLOBALS['wpdb']);
        parent::tearDown();
    }

    public function testRemovesOnlyOwnDataOnEverySite(): void
    {
        if (! defined('WP_UNINSTALL_PLUGIN')) {
            define('WP_UNINSTALL_PLUGIN', 'WP-LIT-MCP/wp-lit-mcp.php');
        }
        $db = Mockery::mock(\wpdb::class)->makePartial();
        $db->expects('query')->twice()->with('DROP TABLE IF EXISTS wp_lit_mcp_log');
        $GLOBALS['wpdb'] = $db;
        Functions\when('is_multisite')->justReturn(true);
        Functions\when('get_sites')->justReturn([1, 2]);
        Functions\expect('switch_to_blog')->twice();
        Functions\expect('restore_current_blog')->twice();
        Functions\expect('delete_transient')->twice()->with(ConnectionStatus::ENDPOINT_TRANSIENT);
        Functions\expect('wp_clear_scheduled_hook')->twice()->with(ChangeLogCleanup::HOOK);
        $deleted = [];
        Functions\when('delete_option')->alias(static function (string $option) use (&$deleted): bool {
            $deleted[] = $option;
            return true;
        });

        require dirname(__DIR__, 2) . '/uninstall.php';

        $own = [Access::OPTION, ExposurePolicy::OPTION, Limits::OPTION, ChangeLogInstaller::VERSION_OPTION];
        self::assertSame(array_merge($own, $own), $deleted);
        self::assertNotContains('mcp_jwt_secret', $deleted);
        self::assertNotContains('wpmedia_mcp_oauth_rewrite_version', $deleted);
    }
}
  • Step 2: Run – muss fehlschlagen

Run: composer test -- --filter 'LifecycleTest|UninstallTest' Expected: FAIL (uninstall.php fehlt; deactivate() löscht Transient/Rewrite-Regeln noch nicht; activate() ruft delete_option nicht auf).

  • Step 3: Implementierung

src/Lifecycle.php (komplett ersetzen):

<?php

declare(strict_types=1);

namespace LIT\MCP;

use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\ChangeLog\ChangeLogCleanup;
use LIT\MCP\ChangeLog\ChangeLogInstaller;
use WPMedia\MCP\OAuth\Bootstrap as OAuthBootstrap;

/**
 * Activation and deactivation hooks (registered in wp-lit-mcp.php). Uninstall
 * lives in uninstall.php.
 */
final class Lifecycle
{
    public static function activate(): void
    {
        $log = new ChangeLog();
        (new ChangeLogInstaller($log))->install();
        (new ChangeLogCleanup($log, new Limits()))->ensureScheduled();

        if (class_exists(OAuthBootstrap::class)) {
            // mcp-oauth's public API: rewrite its /oauth and /.well-known rules on the next init.
            OAuthBootstrap::schedule_rewrite_flush();
        }
    }

    public static function deactivate(): void
    {
        wp_clear_scheduled_hook(ChangeLogCleanup::HOOK);
        delete_transient(ConnectionStatus::ENDPOINT_TRANSIENT);
        // WordPress regenerates the rules on the next request – without those of this now inactive plugin.
        delete_option('rewrite_rules');
    }
}

uninstall.php:

<?php

/**
 * Uninstall WP-LIT-MCP: removes its own data only, on every site of a network.
 *
 * Removed: options lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version,
 * transient lit_mcp_endpoint_check, cron hook lit_mcp_log_cleanup, table {prefix}lit_mcp_log.
 *
 * Kept on purpose: data of the bundled wp-media/mcp-oauth library (option mcp_jwt_secret,
 * option wpmedia_mcp_oauth_rewrite_version, user meta mcp_refresh_jti_*, application passwords
 * of connected clients). The library may be used by other plugins on this site; see readme.txt.
 *
 * Names are duplicated from the plugin classes on purpose – uninstall must not depend on the
 * autoloader. tests/Unit/UninstallTest.php keeps them in sync with the class constants.
 */

declare(strict_types=1);

defined('WP_UNINSTALL_PLUGIN') || exit;

$lit_mcp_uninstall_site = static function (): void {
    global $wpdb;

    foreach (['lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version'] as $option) {
        delete_option($option);
    }
    delete_transient('lit_mcp_endpoint_check');
    wp_clear_scheduled_hook('lit_mcp_log_cleanup');

    $table = $wpdb->prefix . 'lit_mcp_log';
    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name.
    $wpdb->query("DROP TABLE IF EXISTS {$table}");
};

if (is_multisite()) {
    foreach (get_sites(['fields' => 'ids', 'number' => 0]) as $lit_mcp_site_id) {
        switch_to_blog((int) $lit_mcp_site_id);
        $lit_mcp_uninstall_site();
        restore_current_blog();
    }
} else {
    $lit_mcp_uninstall_site();
}

phpcs.xml.dist: nach <file>wp-lit-mcp.php</file> die Zeile <file>uninstall.php</file> einfügen.

  • Step 4: Run – muss bestehen

Run: composer test && composer lint Expected: PASS.

  • Step 5: Integration – Deaktivieren/Aktivieren in DDEV (Deinstallation nicht lokal ausführen)

Run:

cd /home/janniclas/Projekte/WordPress
timeout 120 ddev wp plugin deactivate WP-LIT-MCP
timeout 120 ddev wp cron event list --hook=lit_mcp_log_cleanup --format=count
timeout 120 ddev wp plugin activate WP-LIT-MCP
timeout 120 ddev wp cron event list --hook=lit_mcp_log_cleanup --format=count
timeout 120 ddev wp option get mcp_jwt_secret >/dev/null && echo "mcp-oauth secret kept"

Expected: 0 nach dem Deaktivieren, 1 nach dem Aktivieren, mcp-oauth secret kept. (wp plugin uninstall wird lokal nicht ausgeführt, weil es den Plugin-Ordner löscht; die Deinstallation ist durch UninstallTest abgedeckt.)

  • Step 6: Commit
git add uninstall.php src/Lifecycle.php phpcs.xml.dist tests/Unit/LifecycleTest.php tests/Unit/UninstallTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: clean up own data on deactivation and uninstall

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"

Task 13: Dokumentation, Version 0.2.0, Ende-zu-Ende-Test, manuelle Abnahme

Aus Etappe 0 offen (g): Auswirkungen auf die gesamte Website dokumentieren – als readme.txt (WordPress-Standard, zeigt Administratoren auch „Tested up to“). Dazu die Medien-Anleitung für Claude, die Versionsnummer und der Ende-zu-Ende-Test aller Tools und Admin-Funktionen dieses Plans.

Files:

  • Create: readme.txt, docs/guides/media.md, tests/Integration/admin-render.php, tests/Integration/media-admin-smoke.sh
  • Modify: docs/guides/overview.md (Abschnitt „Werkzeuge“), wp-lit-mcp.php (Version), src/Plugin.php (VERSION)

Interfaces:

  • Consumes: alle Abilities aus 1a/1b, Plugin::settingsPage() und SettingsPage::tabSlugs()/render() (Task 8), tests/Integration/lib.sh (1a), mint-token.php.

  • Produces: readme.txt; admin-render.php (gibt je Tab <slug> ok <bytes> oder <slug> FAIL … aus); media-admin-smoke.sh.

  • Step 1: Readme mit Auswirkungen auf die Website

readme.txt:

=== WP-LIT-MCP ===
Contributors: loosenit
Tags: mcp, claude, divi, pods, ai
Requires at least: 6.9
Tested up to: 7.0
Requires PHP: 8.1
Stable tag: 0.2.0
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai.

== Description ==

WP-LIT-MCP stellt einen MCP-Server bereit, über den Claude (claude.ai-Connector) diese Website pflegt:
Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene
WordPress-Einstellungen und die Mediathek. Jede Änderung läuft mit den Rechten des verbundenen
WordPress-Benutzers, wird protokolliert und ist über Revisionen umkehrbar.

Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, Limits).

== Auswirkungen auf die gesamte Website ==

* Der Standard-MCP-Server von wordpress/mcp-adapter (REST-Route /wp-json/mcp/mcp-adapter-default-server)
  wird abgeschaltet (Filter mcp_adapter_create_default_server). Andere Plugins, die diesen ungeschützten
  Server erwarten, finden ihn nicht mehr.
* Es gibt genau einen MCP-Endpunkt: /wp-json/mcp/mcp-oauth-server (OAuth 2.1 über die eingebundene
  Bibliothek wp-media/mcp-oauth). Anfragen ohne gültiges Token erhalten 401 mit WWW-Authenticate.
* mcp-oauth registriert Rewrite-Regeln für /oauth/… und /.well-known/oauth-… sowie einen
  Website-Zustand-Test. Der Webserver muss /.well-known/ an WordPress durchreichen (bei nginx-Regeln,
  die Punkt-Pfade sperren, eine Ausnahme für /.well-known/ anlegen).
* Jede Verbindung ist ein Anwendungspasswort des Benutzers (Name = Client, z. B. „Claude“). Es ist im
  Benutzerprofil sichtbar und unter Einstellungen → LIT MCP → Zugriff widerrufbar.
* Die Capability lit_mcp_use wird zur Laufzeit an die freigegebenen Rollen vergeben; Rollen in der
  Datenbank werden nicht verändert.
* Eigene Daten: Optionen lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version,
  Tabelle {prefix}lit_mcp_log (nur Verweise, keine Inhalte), täglicher Cron-Job lit_mcp_log_cleanup,
  Transient lit_mcp_endpoint_check.
* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar
  (Standard 5 MB); Import per URL nur von öffentlich erreichbaren Adressen.

== Deaktivieren und Löschen ==

* Deaktivieren entfernt den Cron-Job und lässt WordPress die Rewrite-Regeln neu erzeugen; der
  MCP-Endpunkt und die OAuth-Adressen sind danach nicht mehr erreichbar. Einstellungen und Protokoll
  bleiben erhalten.
* Löschen entfernt zusätzlich alle eigenen Optionen und die Protokolltabelle (auf Multisite je Website).
* Nicht entfernt werden die Daten von mcp-oauth (Option mcp_jwt_secret, Option
  wpmedia_mcp_oauth_rewrite_version, Benutzer-Meta mcp_refresh_jti_*, Anwendungspasswörter der
  Verbindungen), weil diese Bibliothek auch von anderen Plugins genutzt werden kann. Verbindungen
  deshalb vor dem Löschen unter Einstellungen → LIT MCP → Zugriff widerrufen; nicht mehr benötigte
  Anwendungspasswörter lassen sich im Benutzerprofil entfernen.

== Multisite ==

Das Plugin wird je Website aktiviert und konfiguriert; Rollenfreigaben, Tool-Einstellungen und
Protokoll gelten pro Website.

== Changelog ==

= 0.2.0 =
* WordPress-Basis: Schema-Erkennung inkl. Pods, Inhalte lesen/anlegen/ändern, Status, Kopie und
  Austausch, Revisionen, Menüs, WordPress-Einstellungen, Mediathek, Änderungsprotokoll,
  Einstellungsseite, Tool-Exposition, Aufräumen bei Deaktivierung/Deinstallation.

= 0.1.0 =
* Anbindung: OAuth-geschützter MCP-Server, Rollen-Gate, lit-guide, lit-site-status.
  • Step 2: Anleitungen und Version

docs/guides/media.md:

# Medien

- `lit-media-search`: Text, `mime` (z. B. `image` oder `application/pdf`), Zeitraum, `missing_alt: true`
  (Bilder ohne Alt-Text), `unused: true` (nirgends verwendet; prüft höchstens 500 Medien je Aufruf, dann
  mit `next_cursor` weiter).
- `lit-media-preview`: verkleinertes Bild ansehen – z. B. um passende Alt-Texte zu schreiben.
- `lit/media-get`: alle Metadaten, Bildgrößen und Verwendungsorte (Beitragsbild, Inhalte inkl. Divi,
  Bildfelder, Website-Icon).
- Hochladen: `lit-media-upload-from-url` (öffentliche URL) oder `lit/media-upload` (Base64, Limit siehe
  Tool-Beschreibung). Erlaubt: jpg, png, gif, webp, avif, pdf, mp4, mp3.
- `lit/media-update`: Alt-Text, Titel, Bildunterschrift, Beschreibung für bis zu 50 Medien auf einmal;
  `results` meldet jeden Eintrag einzeln.
- `lit/media-download`: Datei als eingebettete Ressource (nur bis zum Upload-Limit).
- `lit/media-delete`: nur nach Rückfrage (`confirm: true`). Wird das Medium verwendet, kommt `in_use`
  mit der Liste der Verwendungen – erst nach ausdrücklicher Zustimmung mit `force: true` löschen. Ohne
  aktivierten Medien-Papierkorb ist das Löschen endgültig.

Alt-Texte beschreiben den Bildinhalt knapp und sachlich (keine „Bild von …“-Floskeln).

docs/guides/overview.md: den Abschnitt ## Werkzeuge (bis vor ## Rückmeldungen) ersetzen durch:

## Werkzeuge

Direkt sichtbar (Standard; Administratoren können das unter Einstellungen → LIT MCP → Tools ändern):
`lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`,
`lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, `lit-media-preview`,
`lit-media-upload-from-url`.

Ü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`), Medien (`lit/media-get`,
`lit/media-upload`, `lit/media-download`, `lit/media-update`, `lit/media-delete`).

Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows` oder `media`.

wp-lit-mcp.php: * Version: 0.1.0 → * Version: 0.2.0. src/Plugin.php: public const VERSION = '0.1.0'; → public const VERSION = '0.2.0';.

  • Step 3: Render-Test der Einstellungsseite

tests/Integration/admin-render.php:

<?php

/**
 * Renders every tab of "Einstellungen → LIT MCP" server-side as an administrator and
 * prints one line per tab: "<slug> ok <bytes>" or "<slug> FAIL <reason>".
 *
 * Usage (inside DDEV): wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/admin-render.php
 *
 * No strict_types: wp eval-file runs this through eval().
 */

defined('WP_CLI') && WP_CLI || exit;

require_once ABSPATH . 'wp-admin/includes/admin.php';

$lit_admins = get_users(['role' => 'administrator', 'number' => 1]);
wp_set_current_user($lit_admins[0]->ID);
set_current_screen('settings_page_lit-mcp');

$lit_page = \LIT\MCP\Plugin::settingsPage();
if ($lit_page === null) {
    echo "FAIL settings page not built\n";
    exit(1);
}

foreach ($lit_page->tabSlugs() as $lit_slug) {
    $_GET['tab'] = $lit_slug;
    ob_start();
    try {
        $lit_page->render();
        $lit_html = (string) ob_get_clean();
    } catch (\Throwable $e) {
        ob_end_clean();
        echo $lit_slug . ' FAIL ' . $e->getMessage() . "\n";
        continue;
    }

    $lit_ok = str_contains($lit_html, 'nav-tab-active') && str_contains($lit_html, 'class="wrap"');
    echo $lit_slug . ($lit_ok ? ' ok ' : ' FAIL ') . strlen($lit_html) . "\n";
}
  • Step 4: Ende-zu-Ende-Skript

tests/Integration/media-admin-smoke.sh:

#!/usr/bin/env bash
# End-to-end checks of Etappe 1b: media tools, exposure policy, visibility for users
# without access, settings page rendering, change log.
# Runs INSIDE the DDEV web container:
#   cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/media-admin-smoke.sh
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)
ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || true)
CREATED_POSTS=()
# 1×1 PNG
PNG='iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='

cleanup() {
  for id in "${CREATED_POSTS[@]}"; do
    wp post delete "$id" --force >/dev/null 2>&1 || true
  done
  if [ -n "$ORIGINAL_EXPOSURE" ]; then
    wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null 2>&1 || true
  else
    wp option delete lit_mcp_exposure >/dev/null 2>&1 || true
  fi
  cleanup_app_passwords_for "$ADMIN_LOGIN"
  cleanup_app_passwords_for lit-smoke-sub2
  wp user delete lit-smoke-sub2 --yes >/dev/null 2>&1 || true
}
trap cleanup EXIT

connect "$ADMIN_LOGIN"

# 1. Direct media tools; image tool without outputSchema
LIST=$(rpc tools/list '{}')
for t in lit-media-search lit-media-preview lit-media-upload-from-url; 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-media-preview") | has("outputSchema") | not' >/dev/null \
  || fail "lit-media-preview must not declare an outputSchema"
pass "direct media tools listed"

# 2. Base64 upload, search, preview, metadata batch, download
UPLOAD=$(ability lit/media-upload "{\"filename\":\"lit-smoke.png\",\"data\":\"$PNG\",\"title\":\"LIT Smoke Bild\"}")
MEDIA_ID=$(echo "$UPLOAD" | jq -r '.attachment.id // empty')
[ -n "$MEDIA_ID" ] || fail "media-upload: $UPLOAD"
CREATED_POSTS+=("$MEDIA_ID")
tool lit-media-search '{"search":"LIT Smoke","missing_alt":true}' \
  | jq -e --argjson id "$MEDIA_ID" '.items[] | select(.id == $id)' >/dev/null || fail "missing_alt search"
PREVIEW=$(rpc tools/call "{\"name\":\"lit-media-preview\",\"arguments\":{\"id\":$MEDIA_ID}}")
echo "$PREVIEW" | jq -e '.result.content[0].type == "image" and (.result.content[0].data | length) > 0' >/dev/null \
  || fail "media-preview: $PREVIEW"
ability lit/media-update "{\"items\":[{\"id\":$MEDIA_ID,\"alt\":\"Testbild\"},{\"id\":999999,\"alt\":\"x\"}]}" \
  | jq -e '.results[0].ok == true and .results[1].ok == false' >/dev/null || fail "media-update batch"
[ "$(wp post meta get "$MEDIA_ID" _wp_attachment_image_alt)" = "Testbild" ] || fail "alt text not stored"
ability lit/media-download "{\"id\":$MEDIA_ID}" | jq -e '.type == "resource" and .resource.mimeType == "image/png"' >/dev/null \
  || fail "media-download"
pass "upload, search, preview, batch update, download"

# 3. Import protection
SSRF=$(tool lit-media-upload-from-url '{"url":"http://127.0.0.1/wp-login.php"}')
[ "$(error_code "$SSRF")" = "validation_failed" ] || fail "internal URL accepted: $SSRF"
FAKE=$(ability lit/media-upload "{\"filename\":\"evil.png\",\"data\":\"$(printf '<?php echo 1;' | base64 -w0)\"}")
[ "$(error_code "$FAKE")" = "validation_failed" ] || fail "disguised PHP accepted: $FAKE"
EXTERNAL=$(tool lit-media-upload-from-url '{"url":"https://s.w.org/images/wmark.png","title":"LIT Smoke Extern"}')
EXTERNAL_ID=$(echo "$EXTERNAL" | jq -r '.attachment.id // empty')
if [ -n "$EXTERNAL_ID" ]; then
  CREATED_POSTS+=("$EXTERNAL_ID")
  pass "import from public URL"
else
  echo "WARN - import from public URL failed (no internet in container?): $EXTERNAL"
fi
pass "internal URLs and disguised files rejected"

# 4. Usage check before delete
PAGE=$(tool lit-content-create "{\"post_type\":\"page\",\"title\":\"LIT Smoke Medien\",\"featured_image\":$MEDIA_ID}")
PAGE_ID=$(echo "$PAGE" | jq -r '.changed[0].id // empty')
[ -n "$PAGE_ID" ] || fail "page with featured image: $PAGE"
CREATED_POSTS+=("$PAGE_ID")
ability lit/media-get "{\"id\":$MEDIA_ID}" | jq -e '.usages[] | select(.where == "featured")' >/dev/null \
  || fail "usage not found"
NOCONFIRM=$(ability lit/media-delete "{\"id\":$MEDIA_ID}")
[ "$(error_code "$NOCONFIRM")" = "confirm_required" ] || fail "delete without confirm: $NOCONFIRM"
INUSE=$(ability lit/media-delete "{\"id\":$MEDIA_ID,\"confirm\":true}")
[ "$(error_code "$INUSE")" = "in_use" ] || fail "delete of used media without force: $INUSE"
ability lit/media-delete "{\"id\":$MEDIA_ID,\"confirm\":true,\"force\":true}" | jq -e '.ok == true' >/dev/null \
  || fail "forced delete"
pass "media delete: confirm, in_use, force"

# 5. Exposure policy
wp option update lit_mcp_exposure '{"lit/content-query":"indirect","lit/menu-list":"disabled"}' --format=json >/dev/null
LIST2=$(rpc tools/list '{}')
echo "$LIST2" | jq -e '.result.tools[] | select(.name == "lit-content-query")' >/dev/null \
  && fail "lit-content-query still direct"
ability lit/content-query '{"post_type":"page","limit":1}' | jq -e 'has("items")' >/dev/null \
  || fail "indirect content-query not executable"
ability lit/menu-list '{}' | jq -e 'has("__error")' >/dev/null || fail "disabled ability still executable"
wp option delete lit_mcp_exposure >/dev/null
pass "exposure policy: indirect and disabled"

# 6. Users without access see neither tools nor site details
wp user create lit-smoke-sub2 lit-smoke-sub2@example.invalid --role=subscriber >/dev/null
ADMIN_TOKEN=$TOKEN
ADMIN_SESSION=$SESSION
connect lit-smoke-sub2
rpc tools/list '{}' | jq -e '.result.tools | length == 0' >/dev/null || fail "subscriber sees tools"
echo "$INIT_RESPONSE" | grep -q 'nicht freigegeben' || fail "subscriber instructions lack the access hint"
echo "$INIT_RESPONSE" | grep -q 'Inhaltstypen' && fail "subscriber instructions leak site details"
TOKEN=$ADMIN_TOKEN
SESSION=$ADMIN_SESSION
pass "no tools and no site details for users without access"

# 7. Settings page renders every tab
RENDER=$(wp eval-file "$PLUGIN/tests/Integration/admin-render.php")
for tab in verbindung zugriff tools protokoll limits; do
  echo "$RENDER" | grep -q "^$tab ok" || fail "settings tab $tab: $RENDER"
done
pass "settings page tabs render"

# 8. Change log
PREFIX=$(wp db prefix)
ROWS=$(wp db query "SELECT COUNT(*) FROM ${PREFIX}lit_mcp_log WHERE ability IN ('lit/media-upload', 'lit/media-delete') AND client = 'lit-mcp-integration-test' AND created_at > (UTC_TIMESTAMP() - INTERVAL 10 MINUTE)" --skip-column-names)
[ "$ROWS" -ge 2 ] || fail "media changes not logged (found $ROWS)"
pass "media changes logged"

echo "ALL CHECKS PASSED"
  • Step 5: Alle Tests ausführen

Run:

cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test && composer lint
cd /home/janniclas/Projekte/WordPress
timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/mcp-smoke.sh
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/media-admin-smoke.sh

Expected: Unit-Tests und Lint grün; alle drei Skripte ALL CHECKS PASSED (die Warnung zum Import von einer öffentlichen URL ist zulässig, wenn der Container kein Internet hat).

  • Step 6: Commit
git add readme.txt docs/guides wp-lit-mcp.php src/Plugin.php tests/Integration/admin-render.php tests/Integration/media-admin-smoke.sh
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "docs: readme with site-wide effects, media guide; test: media/admin end-to-end; version 0.2.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
  • Step 7: Manuelle Abnahme (mit Jan-Niclas, Browser)

Der ausführende Agent bereitet vor und meldet; die Punkte prüft Jan-Niclas selbst:

  1. Einstellungen → LIT MCP: alle fünf Tabs öffnen; Rolle „Redakteur“ freigeben und wieder entfernen; im Tab „Tools“ ein Tool auf „indirekt“ stellen, speichern, zurück auf „Standard“.
  2. claude.ai-Connector verbinden (Staging oder ddev share), die Verbindung erscheint unter „Zugriff“; „Widerrufen“ → der nächste Tool-Aufruf in claude.ai scheitert mit 401 und verlangt eine neue Anmeldung.
  3. In claude.ai: „Suche Bilder ohne Alt-Text, zeig mir eins und schlag einen Alt-Text vor“ – lit-media-preview liefert ein sichtbares Bild.
  4. Werkzeuge → Website-Zustand: Test „LIT MCP Endpunkt“ ist grün (bzw. zeigt verständlich, was fehlt).
  5. Protokoll-Tab: Einträge der Tests sichtbar, Filter nach Tool funktioniert, Revisions-Link öffnet den WordPress-Revisionsvergleich, „Protokoll leeren“ leert die Liste.

Ergebnisse als kurze Notiz an den Ausführenden zurückmelden; Abweichungen werden als Folgeaufgaben erfasst.


Self-Review (durchgeführt beim Schreiben)

  • Spec-Abdeckung (Teil 1b): §6.3 Medien komplett (search inkl. missing_alt/unused, get mit Verwendungsorten, preview als ImageContent ohne outputSchema, upload-from-url mit wp_safe_remote_get/Whitelist/Limit, Base64-Upload mit einstellbarem Limit, download als Blob-Ressource, Stapel-Update, delete mit confirm/force) → Tasks 1–5; §4.3 Exposition direkt/indirekt/deaktiviert → Task 6 + Tab in Task 10; §8.3 Admin-Ansicht, Filter, Revisions-Links, Leeren → Task 11; §8.4 Verbindung (URL, Status, Site-Health), Zugriff (Rollen, Verbindungen, Widerruf), Tools, Limits → Tasks 8–10; §10 HTTPS-Hinweis, SSRF-Schutz, MIME-Whitelist, Größenlimits → Tasks 4, 8. Etappe-0-Rückstände: (a) → Task 6, (b) → Task 7, (c) → Task 12, (d) → Task 8, (g) → Task 13; (e)/(f) erledigt in 1a.
  • Bewusste Entscheidungen: Discovery-Prüfung nutzt den vorhandenen Site-Health-Test von mcp-oauth, LIT MCP ergänzt nur den Endpunkt-Test; Admin-Hinweise lesen nur gecachte Ergebnisse. Deaktivierte Abilities werden gar nicht registriert. lit/media-preview kann nicht indirekt, lit/guide nicht deaktiviert werden. Medien ohne aktivierten MEDIA_TRASH werden (WordPress-Verhalten) endgültig gelöscht – nur nach confirm und Verwendungsprüfung. Build mit composer install --no-dev (Ruling 12 aus Etappe 0) gehört zur Etappe U und ist hier nicht enthalten.
  • Typ-Konsistenz geprüft: ExposurePolicy-Methoden in Registrar/Server/ToolsTab, ConnectionStatus-Methoden in Tab/Notices/SiteHealth, ChangeLog::query/abilities/clear in LogTab/LogListTable, MediaUsageFinder::usages/isUsed in get/search/delete, MediaImporter::fromUrl/fromBase64 in beiden Upload-Tools stimmen mit den Interfaces-Blöcken überein.