Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
228 KiB
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-MCPanlegen 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.txtmitTested up to: 7.0. - Namespace
LIT\MCP\→src/, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile;composer lintundcomposer testnach jedem Task sauber. - Unit-Tests über
LIT\MCP\Tests\Unit\TestCase(Brain Monkey + Mockery); die dort perFunctions\stubs()vorbelegten Funktionen (wp_json_encode,is_wp_error,absint,wp_slash,wp_unslash,sanitize_text_field) nie mitFunctions\expect()prüfen. Admin-Ausgaben mitFunctions\stubEscapeFunctions()undFunctions\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 (readonlyfür Lese-Tools,destructivenur fürlit/media-delete); Input-/Output-Schemas als JSON Schema 2020-12 im vonrest_validate_value_from_schema()geprüften Umfang; Tools mit Bild- oder Ressourcen-Ergebnis (lit/media-preview,lit/media-download) haben keinoutputSchema. - Fachliche Fehler nur über
Errors(lit_*, JSON mithint); Schreib-Tools liefernToolResultund schreiben einen Protokolleintrag; Stapel-Tools melden das Ergebnis je Eintrag. - Medien: Import nur über
download_url()(nutztwp_safe_remote_get, SSRF-Schutz) nach Vorprüfung mitwp_http_validate_url(); MIME-Whitelist (Filterlit_mcp_upload_mimes); Größenlimit ausLimits(Standard 5 MB);lit/media-deletenur mitconfirm: trueund bei Verwendung nur mitforce: 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 undmanage_options. Für das Divi-Code-Modul gibt es keine Einstellung. - Aufräumen: nur eigene Daten (Optionen
lit_mcp_*, Tabelle{prefix}lit_mcp_log, Cronlit_mcp_log_cleanup, Transientlit_mcp_endpoint_check); Daten von mcp-oauth (mcp_jwt_secret,wpmedia_mcp_oauth_rewrite_version, Benutzer-Metamcp_refresh_jti_*, Anwendungspasswörter) bleiben unangetastet. - Commits:
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …mit den TrailernCo-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>undClaude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7.
Review Focus
- Medium wird noch verwendet (Beitragsbild, Divi-Bildmodul
"id":"<ID>",wp-image-<ID>, Bildgröße im Inhalt, Pods-Bildfeld, Website-Icon):lit-media-deletemitconfirmaber ohneforce→lit_in_usemit Verwendungsliste, nichts gelöscht → Tests in Task 1 (Finder) und Task 5. - Import-Missbrauch: URL auf
127.0.0.1/interne Hosts, Datei über dem Limit,.phpals.pnggetarnt, ungültiges Base64 → abgelehnt, temporäre Datei gelöscht, kein Anhang → Tests in Task 4. - Unsinnige Tool-Einstellung:
lit/media-previewauf „indirekt“ (Bildergebnisse gehen nur direkt) oderlit/guideauf „deaktiviert“ → Policy fällt auf den Standard zurück; kaputte Optionswerte werden ignoriert → Test in Task 6. - Benutzer ohne
lit_mcp_usemit gültigem Token:tools/listleer, Instructions ohne Website-Details; Listen fremder MCP-Server unverändert → Tests in Task 7. - 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(MethodemediaFieldKeys()),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 mittype === 'file'oderrelated.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}, OutputfullSchema(); 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.unusedprüft höchstensMAX_SCAN = 500Medien je Aufruf (in Blöcken zu 50) und meldet eine Unterbrechung als Warnung mitnext_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ädtwp-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; Filterlit_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-Extraattachment(summary).MediaUploadAbility(MediaImporter $importer, MediaPresenter $presenter, ChangeLog $log, Limits $limits)–lit/media-upload, Input{filename, data (Base64), title?, alt?, post_id?}; indirekt; Ergebnis-Extraattachment.- Beide: Recht
upload_files, beipost_idzusätzlichedit_postfü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-Extraresults: 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-Extrausages; indirekt;destructive: true, nicht idempotent;changed.action=trashedoderdeleted.
-
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 giltnew ExposurePolicy()=isDirect()). - Entfernt:
IntegrationRegistry::directAbilityNames()(einziger Aufrufer warServerRegistrar).
-
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(aufadmin_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)– slugverbindung.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)– slugzugriff;GROUP = 'lit_mcp_access',REVOKE_ACTION = 'lit_mcp_revoke';static sanitizeRoles(mixed $input): list<string>;handleRevoke(): void(admin-post, Noncelit_mcp_revoke_<uuid>,manage_options).- Test-Stub
WP_Application_Passwordsmit 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)– slugtools,GROUP = 'lit_mcp_tools';LimitsTab()– sluglimits,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"
Task 11: Tab „Protokoll“ – Liste mit Filtern, Links zu Revisionen, „Protokoll leeren“
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)– slugprotokoll,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(Optionenlit_mcp_allowed_roles,lit_mcp_exposure,lit_mcp_limits,lit_mcp_db_version, Transientlit_mcp_endpoint_check, Cronlit_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()undSettingsPage::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:
- 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“.
- 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. - In claude.ai: „Suche Bilder ohne Alt-Text, zeig mir eins und schlag einen Alt-Text vor“ –
lit-media-previewliefert ein sichtbares Bild. - Werkzeuge → Website-Zustand: Test „LIT MCP Endpunkt“ ist grün (bzw. zeigt verständlich, was fehlt).
- 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 alsImageContentohneoutputSchema, upload-from-url mitwp_safe_remote_get/Whitelist/Limit, Base64-Upload mit einstellbarem Limit, download als Blob-Ressource, Stapel-Update, delete mitconfirm/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-previewkann nicht indirekt,lit/guidenicht deaktiviert werden. Medien ohne aktiviertenMEDIA_TRASHwerden (WordPress-Verhalten) endgültig gelöscht – nur nachconfirmund Verwendungsprüfung. Build mitcomposer 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/clearin LogTab/LogListTable,MediaUsageFinder::usages/isUsedin get/search/delete,MediaImporter::fromUrl/fromBase64in beiden Upload-Tools stimmen mit den Interfaces-Blöcken überein.