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

6083 lines
228 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Etappe 1b – WordPress-Basis: Medien, Einstellungsseite, Exposition, Aufräumen – Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Claude verwaltet die Mediathek (suchen, ansehen inkl. Vorschaubild, Upload per URL und Datei, Download, Metadaten im Stapel, Löschen mit Verwendungsprüfung); Administratoren steuern LIT MCP über „Einstellungen → LIT MCP“ (Verbindung, Zugriff inkl. Widerruf, Tools direkt/indirekt/deaktiviert, Protokoll, Limits); nicht freigegebene Benutzer sehen keine Tools; Deaktivieren/Löschen räumt die eigenen Daten auf.
**Architecture:** Neue Integration `MediaIntegration` mit `MediaUsageFinder` (Beitragsbilder, Inhalte inkl. Divi-Attribute, Datei-/Bildfelder, Website-Icon) und `MediaImporter` (SSRF-geschützter Download, MIME-Whitelist, Größenlimit). `ExposurePolicy` ersetzt `Ability::isDirect()` als Entscheidung (bleibt Standard) für `AbilityRegistrar` und `ServerRegistrar`. `VisibilityFilter` nutzt die Adapter-Filter `mcp_adapter_tools_list` und `mcp_adapter_initialize_response`. Die Einstellungsseite besteht aus einer `SettingsPage` mit `AdminTab`-Implementierungen (Settings API, `WP_List_Table`, kein JS-Framework). `uninstall.php` und `Lifecycle::deactivate()` räumen nur eigene Daten auf.
**Tech Stack:** PHP ≥ 8.1, WordPress 7.0 (Abilities API, Settings API, `WP_List_Table`, Site Health), `wordpress/mcp-adapter` 0.5.0, `wp-media/mcp-oauth` 1.1.2, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs, DDEV.
**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §4.3, §4.5, §6.3, §8.1–8.4, §10, §11.3, §12 Etappe 1)
**Voraussetzung:** Plan `docs/superpowers/plans/2026-09-25-etappe-1a-wordpress-inhalte.md` ist vollständig umgesetzt (u. a. `Errors`, `ToolResult`, `Cursor`, `JsonMap`, `ChangeLog`, `Limits`, `Lifecycle`, `ContentSchema`, `PostGuard`, `WordPressIntegration`, `tests/Integration/lib.sh`, Stubs `WP_Post`, `WP_Term`, `wpdb`).
## Global Constraints
- Dateien ausschließlich im Plugin-Verzeichnis `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Keine neuen Plugins installieren; Pods darf für Integrationstests aktiviert und muss danach wieder deaktiviert werden.
- PHP ≥ 8.1, WordPress ≥ 6.9 (getestet mit 7.0.3), Plugin-Header und `readme.txt` mit `Tested up to: 7.0`.
- Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, PSR-12, höchstens 120 Zeichen pro Zeile; `composer lint` und `composer test` nach jedem Task sauber.
- Unit-Tests über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); die dort per `Functions\stubs()` vorbelegten Funktionen (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. Admin-Ausgaben mit `Functions\stubEscapeFunctions()` und `Functions\stubTranslationFunctions()` testen.
- Integrationstests im DDEV-Container: `cd /home/janniclas/Projekte/WordPress && timeout 600 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh`.
- Ability-Namen `lit/<bereich>-<aktion>`; Annotations exakt (`readonly` für Lese-Tools, `destructive` nur für `lit/media-delete`); Input-/Output-Schemas als JSON Schema 2020-12 im von `rest_validate_value_from_schema()` geprüften Umfang; Tools mit Bild- oder Ressourcen-Ergebnis (`lit/media-preview`, `lit/media-download`) haben **kein** `outputSchema`.
- Fachliche Fehler nur über `Errors` (`lit_*`, JSON mit `hint`); Schreib-Tools liefern `ToolResult` und schreiben einen Protokolleintrag; Stapel-Tools melden das Ergebnis je Eintrag.
- Medien: Import nur über `download_url()` (nutzt `wp_safe_remote_get`, SSRF-Schutz) nach Vorprüfung mit `wp_http_validate_url()`; MIME-Whitelist (Filter `lit_mcp_upload_mimes`); Größenlimit aus `Limits` (Standard 5 MB); `lit/media-delete` nur mit `confirm: true` und bei Verwendung nur mit `force: true`.
- Einstellungsseite „Einstellungen → LIT MCP“ (`options-general.php?page=lit-mcp`), Tabs Verbindung, Zugriff, Tools, Protokoll, Limits; Settings API, `WP_List_Table`, kein JS-Framework; alle Formulare mit Nonce und `manage_options`. Für das Divi-Code-Modul gibt es keine Einstellung.
- Aufräumen: nur eigene Daten (Optionen `lit_mcp_*`, Tabelle `{prefix}lit_mcp_log`, Cron `lit_mcp_log_cleanup`, Transient `lit_mcp_endpoint_check`); Daten von mcp-oauth (`mcp_jwt_secret`, `wpmedia_mcp_oauth_rewrite_version`, Benutzer-Meta `mcp_refresh_jti_*`, Anwendungspasswörter) bleiben unangetastet.
- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit …` mit den Trailern `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>` und `Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7`.
## Review Focus
1. **Medium wird noch verwendet** (Beitragsbild, Divi-Bildmodul `"id":"<ID>"`, `wp-image-<ID>`, Bildgröße im Inhalt, Pods-Bildfeld, Website-Icon): `lit-media-delete` mit `confirm` aber ohne `force` → `lit_in_use` mit Verwendungsliste, nichts gelöscht → Tests in Task 1 (Finder) und Task 5.
2. **Import-Missbrauch:** URL auf `127.0.0.1`/interne Hosts, Datei über dem Limit, `.php` als `.png` getarnt, ungültiges Base64 → abgelehnt, temporäre Datei gelöscht, kein Anhang → Tests in Task 4.
3. **Unsinnige Tool-Einstellung:** `lit/media-preview` auf „indirekt“ (Bildergebnisse gehen nur direkt) oder `lit/guide` auf „deaktiviert“ → Policy fällt auf den Standard zurück; kaputte Optionswerte werden ignoriert → Test in Task 6.
4. **Benutzer ohne `lit_mcp_use` mit gültigem Token:** `tools/list` leer, Instructions ohne Website-Details; Listen fremder MCP-Server unverändert → Tests in Task 7.
5. **Löschen des Plugins:** eigene Optionen, Tabelle, Cron und Transient weg (auf Multisite je Website), mcp-oauth-Daten bleiben → Test in Task 12.
---
## File Structure
```
wp-lit-mcp.php (geändert) Version 0.2.0
readme.txt WordPress-Readme: Auswirkungen auf die Website, Deinstallation
uninstall.php entfernt eigene Daten
phpcs.xml.dist (geändert) uninstall.php prüfen
src/
Plugin.php (geändert) Media, Policy, Sichtbarkeit, Admin
Lifecycle.php (geändert) Deaktivierung räumt Rewrite/Transient auf
Abilities/
AbilityRegistrar.php (geändert) registriert nur nicht deaktivierte Abilities
ExposurePolicy.php direkt / indirekt / deaktiviert je Ability
ChangeLog/ChangeLog.php (geändert) query, abilities, clear
Integrations/
IntegrationRegistry.php (geändert) directAbilityNames() entfällt
WordPress/Schema/ContentSchema.php (geändert) mediaFieldKeys()
Media/
MediaIntegration.php liefert die Medien-Abilities
MediaGuard.php lädt Anhang + Rechteprüfung
MediaPresenter.php Ausgabeformat eines Mediums
MediaUsageFinder.php Verwendungssuche
MediaImporter.php Upload per URL/Base64 mit Whitelist und Limit
AdminIncludes.php lädt wp-admin/includes für Medienfunktionen
MediaSearchAbility.php lit/media-search
MediaGetAbility.php lit/media-get
MediaPreviewAbility.php lit/media-preview (ImageContent)
MediaDownloadAbility.php lit/media-download (eingebettete Ressource)
MediaUploadFromUrlAbility.php lit/media-upload-from-url
MediaUploadAbility.php lit/media-upload
MediaUpdateAbility.php lit/media-update (Stapel)
MediaDeleteAbility.php lit/media-delete
Server/
ServerRegistrar.php (geändert) Tool-Liste aus ExposurePolicy
VisibilityFilter.php Tool-Liste/Instructions für Nicht-Freigegebene
Admin/
AdminTab.php Interface eines Tabs
SettingsPage.php Menüpunkt, Tabs, Hinweise
ConnectionStatus.php Adapter/OAuth/HTTPS/Discovery/Endpunkt-Prüfung
ConnectionTab.php Tab „Verbindung“
AdminNotices.php Admin-Hinweise (OAuth aus, Discovery kaputt, HTTPS)
SiteHealth.php Site-Health-Test „LIT MCP Endpunkt“
ConnectionRepository.php OAuth-Verbindungen (Anwendungspasswörter von mcp-oauth)
AccessTab.php Tab „Zugriff“: Rollen, Verbindungen, Widerruf
ToolsTab.php Tab „Tools“: Exposition je Ability
LimitsTab.php Tab „Limits“
LogTab.php Tab „Protokoll“: Filter, Tabelle, Leeren
LogListTable.php WP_List_Table des Protokolls
docs/guides/overview.md (geändert) Medien-Werkzeuge
docs/guides/media.md Anleitung Medien
tests/
Stubs/WpApplicationPasswords.php Stub für Verbindungen
Unit/… Tests je Klasse
Unit/UninstallTest.php prüft uninstall.php
Integration/admin-render.php rendert alle Tabs serverseitig
Integration/media-admin-smoke.sh Ende-zu-Ende-Test Medien, Policy, Sichtbarkeit, Admin
```
---
### Task 1: Medien-Grundlagen – `MediaGuard`, `MediaPresenter`, `MediaUsageFinder`, `lit/media-get`, `MediaIntegration`
Verwendungssuche (Spec §6.3): Anhang-ID als Beitragsbild, im Inhalt (`wp-image-<ID>`-Klasse, Divi-5-Attribut `"id":"<ID>"`, Dateipfad inkl. Bildgrößen), in Datei-/Bildfeldern (Pods-`file`-Felder und Beziehungen auf Medien, Schlüssel aus `ContentSchema`) und als Website-Icon.
**Files:**
- Create: `src/Integrations/Media/MediaGuard.php`, `src/Integrations/Media/MediaPresenter.php`, `src/Integrations/Media/MediaUsageFinder.php`, `src/Integrations/Media/MediaGetAbility.php`, `src/Integrations/Media/MediaIntegration.php`
- Modify: `src/Integrations/WordPress/Schema/ContentSchema.php` (Methode `mediaFieldKeys()`), `src/Plugin.php` (Integration registrieren)
- Test: `tests/Unit/Integrations/Media/MediaUsageFinderTest.php`, `tests/Unit/Integrations/Media/MediaPresenterTest.php`, `tests/Unit/Integrations/Media/MediaGetAbilityTest.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php`, `tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php` (Test ergänzen)
**Interfaces:**
- Consumes: `PostGuard::id()`, `ContentSchema::postTypeNames()/fields()`, `FieldDefinition` (1a), `Errors`, `JsonMap`, `ChangeLog`, `Limits` (1a).
- Produces:
- `ContentSchema::mediaFieldKeys(): list<string>` – Schlüssel aller Felder mit `type === 'file'` oder `related.object_type === 'media'` über alle verwalteten Typen.
- `MediaGuard::attachment(mixed $id, string $capability = 'edit_post'): WP_Post|WP_Error`.
- `MediaPresenter` (nicht final): `summary(WP_Post $a): array{id, title, filename, mime_type, url, alt, width, height, filesize, date}`, `full(WP_Post $a, list<array> $usages): array` (+ `caption`, `description`, `parent`, `sizes` (Map Name → `{url,width,height}`), `usages`, `edit_url`), `static summarySchema(): array`, `static fullSchema(): array`.
- `MediaUsageFinder` (nicht final): `__construct(Closure $mediaMetaKeys, ?\wpdb $db = null)`; `usages(int $attachmentId, int $max = 20): list<array{post_id: int, title: string, post_type: string, where: 'featured'|'content'|'field'|'site_icon'}>`; `isUsed(int $attachmentId): bool`.
- `MediaGetAbility(MediaPresenter $presenter, MediaUsageFinder $finder)` – `lit/media-get`, Input `{id}`, Output `fullSchema()`; indirekt; readonly.
- `MediaIntegration`: `__construct(ContentSchema $schema, ChangeLog $log, Limits $limits)`, `id()` = `media`, immer verfügbar; `abilities()` wächst in Tasks 2–5.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Media/MediaUsageFinderTest.php`:
```php
<?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
<?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
<?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
<?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:
```php
public function testMediaFieldKeysCoverFileFieldsAndMediaRelations(): void
{
Functions\when('get_post_types')->justReturn(['page' => 'page', 'lit_person' => 'lit_person']);
$source = Mockery::mock(FieldSource::class);
$source->allows('id')->andReturn('pods');
$source->allows('fieldsFor')->andReturnUsing(static fn (string $type): array => $type === 'lit_person'
? [
new FieldDefinition('photo', 'Foto', 'file', 'pods', 'writable'),
new FieldDefinition('logo', 'Logo', 'relation', 'pods', 'writable', false, false, null, [
'object_type' => 'media',
'name' => null,
]),
new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
]
: [new FieldDefinition('photo', 'Foto', 'file', 'pods', 'writable')]);
self::assertSame(['photo', 'logo'], (new ContentSchema([$source]))->mediaFieldKeys());
}
```
- [ ] **Step 2: Run – muss fehlschlagen**
Run: `cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP && composer test -- --filter 'Media|ContentSchemaTest'`
Expected: FAIL (Klassen fehlen, `mediaFieldKeys()` fehlt).
- [ ] **Step 3: Implementierung**
In `src/Integrations/WordPress/Schema/ContentSchema.php` nach `sourceOf()` ergänzen:
```php
/**
* Keys of fields that store attachment IDs (file fields, relations to media) –
* used by the media usage search.
*
* @return list<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
<?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
<?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
<?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
<?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
<?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:
```php
public static function boot(string $pluginFile): void
{
self::$pluginFile = $pluginFile;
$missing = (new Dependencies())->missing();
if ($missing !== []) {
self::noticeMissing($missing);
return;
}
(new Access())->register();
(new RequestContext())->register();
$limits = new Limits();
$changeLog = new ChangeLog();
(new ChangeLogInstaller($changeLog))->maybeInstall();
(new ChangeLogCleanup($changeLog, $limits))->register();
$schema = self::contentSchema();
$registry = new IntegrationRegistry(
new CoreIntegration(self::pluginDir() . '/docs/guides'),
new WordPressIntegration($schema, $changeLog),
new MediaIntegration($schema, $changeLog, $limits)
);
(new AbilityRegistrar($registry))->register();
(new ServerRegistrar(
$registry,
new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames())
))->register();
\WPMedia\MCP\OAuth\Bootstrap::instance();
}
```
- [ ] **Step 4: Run – muss bestehen**
Run: `composer test && composer lint`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Integrations/Media src/Integrations/WordPress/Schema/ContentSchema.php src/Plugin.php tests/Unit/Integrations/Media tests/Unit/Integrations/WordPress/Schema/ContentSchemaTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media usage finder and media-get tool
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 2: `lit/media-search` inkl. `missing_alt` und `unused`
**Files:**
- Create: `src/Integrations/Media/MediaSearchAbility.php`
- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php`
- Test: `tests/Unit/Integrations/Media/MediaSearchAbilityTest.php`
**Interfaces:**
- Consumes: `MediaPresenter::summary()/summarySchema()`, `MediaUsageFinder::isUsed()` (Task 1), `Cursor` (1a).
- Produces: `MediaSearchAbility(MediaPresenter $presenter, MediaUsageFinder $finder)` – `lit/media-search`, Input `{search?, mime?, date_after?, date_before?, missing_alt?, unused?, limit?, cursor?}`, Output `{items: list<summary>, next_cursor: ?string, warnings: list<string>}`; direkt; readonly, idempotent. `unused` prüft höchstens `MAX_SCAN = 500` Medien je Aufruf (in Blöcken zu 50) und meldet eine Unterbrechung als Warnung mit `next_cursor`.
- [ ] **Step 1: Failing Test**
`tests/Unit/Integrations/Media/MediaSearchAbilityTest.php`:
```php
<?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:
```php
private const EXPECTED = [
'lit/media-search',
'lit/media-get',
];
```
- [ ] **Step 2: Run – muss fehlschlagen**
Run: `composer test -- --filter 'MediaSearchAbilityTest|MediaIntegrationTest'`
Expected: FAIL.
- [ ] **Step 3: Implementierung**
`src/Integrations/Media/MediaSearchAbility.php`:
```php
<?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
```php
return [
new MediaSearchAbility($presenter, $finder),
new MediaGetAbility($presenter, $finder),
];
```
- [ ] **Step 4: Run – muss bestehen**
Run: `composer test && composer lint`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Integrations/Media tests/Unit/Integrations/Media
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media-search with missing_alt and unused filters
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 3: `lit/media-preview` (ImageContent) und `lit/media-download` (eingebettete Ressource)
Der Adapter wandelt ein Ergebnis `['type' => 'image', 'results' => <Bytes>, 'mimeType' => …]` in `ImageContent` und `['type' => 'resource', 'resource' => ['uri', 'mimeType', 'blob']]` in eine eingebettete Ressource um (`ToolsHandler::call_tool`). Solche Tools haben kein `outputSchema`; Bilder funktionieren nur als direktes Tool (Spec §4.3) – `ExposurePolicy` erzwingt das in Task 6.
**Files:**
- Create: `src/Integrations/Media/AdminIncludes.php`, `src/Integrations/Media/MediaPreviewAbility.php`, `src/Integrations/Media/MediaDownloadAbility.php`
- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php`
- Test: `tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php`, `tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php`
**Interfaces:**
- Consumes: `MediaGuard` (Task 1), `Limits::uploadBytes()/uploadMegabytes()` (1a), `Errors`.
- Produces:
- `AdminIncludes::load(): void` – lädt `wp-admin/includes/file.php`, `media.php`, `image.php`, sofern deren Funktionen fehlen und die Datei existiert.
- `MediaPreviewAbility()` – `lit/media-preview`, Input `{id, max_size? (64–1024, Standard 512)}`, Ergebnis `{type: 'image', results: <Bytes>, mimeType}`; direkt; readonly, idempotent; `outputSchema()` = `null`.
- `MediaDownloadAbility(Limits $limits)` – `lit/media-download`, Input `{id, size? (Standard full)}`, Ergebnis `{type: 'resource', resource: {uri, mimeType, blob}}`; indirekt; readonly, idempotent; `outputSchema()` = `null`; Dateien über dem Upload-Limit werden abgelehnt.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Media/MediaPreviewAbilityTest.php`:
```php
<?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
<?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:
```php
private const EXPECTED = [
'lit/media-search',
'lit/media-get',
'lit/media-preview',
'lit/media-download',
];
```
- [ ] **Step 2: Run – muss fehlschlagen**
Run: `composer test -- --filter 'MediaPreviewAbilityTest|MediaDownloadAbilityTest|MediaIntegrationTest'`
Expected: FAIL.
- [ ] **Step 3: Implementierung**
`src/Integrations/Media/AdminIncludes.php`:
```php
<?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
<?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
<?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
```php
return [
new MediaSearchAbility($presenter, $finder),
new MediaGetAbility($presenter, $finder),
new MediaPreviewAbility(),
new MediaDownloadAbility($this->limits),
];
```
- [ ] **Step 4: Run – muss bestehen**
Run: `composer test && composer lint`
Expected: PASS (lange Zeilen umbrechen).
- [ ] **Step 5: Commit**
```bash
git add src/Integrations/Media tests/Unit/Integrations/Media
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media preview as image content and download as embedded resource
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 4: Upload – `MediaImporter`, `lit/media-upload-from-url`, `lit/media-upload`
Spec §6.3/§10: Import per URL über `wp_safe_remote_get` (hier `download_url()`, das intern `wp_safe_remote_get` nutzt; vorher `wp_http_validate_url()`, damit interne Adressen gar nicht erst angefragt werden), MIME-Whitelist, Größenlimit; Base64-Upload mit einstellbarem Limit (Standard 5 MB). `wp_check_filetype_and_ext()` prüft zusätzlich den echten Dateiinhalt (getarnte Dateien fallen durch).
**Files:**
- Create: `src/Integrations/Media/MediaImporter.php`, `src/Integrations/Media/MediaUploadFromUrlAbility.php`, `src/Integrations/Media/MediaUploadAbility.php`
- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php`
- Test: `tests/Unit/Integrations/Media/MediaImporterTest.php`, `tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php`
**Interfaces:**
- Consumes: `AdminIncludes::load()` (Task 3), `MediaPresenter::summary()/summarySchema()` (Task 1), `Limits`, `ChangeLog`, `ToolResult`, `Errors`.
- Produces:
- `MediaImporter` (nicht final): `MIMES` (Endungen → MIME: jpg/jpeg/jpe, png, gif, webp, avif, pdf, mp4/m4v, mp3; Filter `lit_mcp_upload_mimes`); `__construct(Limits $limits)`; `fromUrl(string $url, ?string $filename, array $meta): int|WP_Error`; `fromBase64(string $data, string $filename, array $meta): int|WP_Error` (`$meta` = `{title?, alt?, post_id?}`); löscht die temporäre Datei bei jedem Fehler.
- `MediaUploadFromUrlAbility(MediaImporter $importer, MediaPresenter $presenter, ChangeLog $log)` – `lit/media-upload-from-url`, Input `{url, filename?, title?, alt?, post_id?}`; direkt; Ergebnis-Extra `attachment` (summary).
- `MediaUploadAbility(MediaImporter $importer, MediaPresenter $presenter, ChangeLog $log, Limits $limits)` – `lit/media-upload`, Input `{filename, data (Base64), title?, alt?, post_id?}`; indirekt; Ergebnis-Extra `attachment`.
- Beide: Recht `upload_files`, bei `post_id` zusätzlich `edit_post` für diesen Eintrag; nicht idempotent.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Media/MediaImporterTest.php`:
```php
<?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
<?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:
```php
private const EXPECTED = [
'lit/media-search',
'lit/media-get',
'lit/media-preview',
'lit/media-upload-from-url',
'lit/media-upload',
'lit/media-download',
];
```
- [ ] **Step 2: Run – muss fehlschlagen**
Run: `composer test -- --filter 'MediaImporterTest|MediaUploadAbilitiesTest|MediaIntegrationTest'`
Expected: FAIL.
- [ ] **Step 3: Implementierung**
`src/Integrations/Media/MediaImporter.php`:
```php
<?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
<?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
<?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
```php
public function abilities(): array
{
$presenter = new MediaPresenter();
$finder = new MediaUsageFinder(fn (): array => $this->schema->mediaFieldKeys());
$importer = new MediaImporter($this->limits);
return [
new MediaSearchAbility($presenter, $finder),
new MediaGetAbility($presenter, $finder),
new MediaPreviewAbility(),
new MediaUploadFromUrlAbility($importer, $presenter, $this->log),
new MediaUploadAbility($importer, $presenter, $this->log, $this->limits),
new MediaDownloadAbility($this->limits),
];
}
```
- [ ] **Step 4: Run – muss bestehen**
Run: `composer test && composer lint`
Expected: PASS (lange Zeilen umbrechen).
- [ ] **Step 5: Commit**
```bash
git add src/Integrations/Media tests/Unit/Integrations/Media
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: media upload by URL and base64 with whitelist and size limit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 5: `lit/media-update` (Stapel) und `lit/media-delete` (mit Verwendungsprüfung)
WordPress hat für Medien standardmäßig keinen Papierkorb (`MEDIA_TRASH` = false): `wp_delete_attachment($id, false)` legt nur bei aktivem Medien-Papierkorb in den Papierkorb, sonst löscht es endgültig. Deshalb verlangt das Tool immer `confirm: true` und sagt in der Rückfrage, was passieren wird.
**Files:**
- Create: `src/Integrations/Media/MediaUpdateAbility.php`, `src/Integrations/Media/MediaDeleteAbility.php`
- Modify: `src/Integrations/Media/MediaIntegration.php`, `tests/Unit/Integrations/Media/MediaIntegrationTest.php`
- Test: `tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php`, `tests/Unit/Integrations/Media/MediaDeleteAbilityTest.php`
**Interfaces:**
- Consumes: `MediaGuard`, `MediaUsageFinder::usages()` (Task 1), `ChangeLog`, `ToolResult`, `Errors`.
- Produces:
- `MediaUpdateAbility(ChangeLog $log)` – `lit/media-update`, Input `{items: list<{id, alt?, title?, caption?, description?}>}` (1–50); Ergebnis-Extra `results: list<{id, ok, error: ?string, log_id: ?int}>`; indirekt; idempotent; Fehler einzelner Einträge brechen die anderen nicht ab.
- `MediaDeleteAbility(MediaUsageFinder $finder, ChangeLog $log, ?callable $mediaTrash = null)` – `lit/media-delete`, Input `{id, confirm?, force?}`; Ergebnis-Extra `usages`; indirekt; `destructive: true`, nicht idempotent; `changed.action` = `trashed` oder `deleted`.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Integrations/Media/MediaUpdateAbilityTest.php`:
```php
<?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
<?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:
```php
private const EXPECTED = [
'lit/media-search',
'lit/media-get',
'lit/media-preview',
'lit/media-upload-from-url',
'lit/media-upload',
'lit/media-download',
'lit/media-update',
'lit/media-delete',
];
```
- [ ] **Step 2: Run – muss fehlschlagen**
Run: `composer test -- --filter 'MediaUpdateAbilityTest|MediaDeleteAbilityTest|MediaIntegrationTest'`
Expected: FAIL.
- [ ] **Step 3: Implementierung**
`src/Integrations/Media/MediaUpdateAbility.php`:
```php
<?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
<?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:
```php
new MediaUpdateAbility($this->log),
new MediaDeleteAbility($finder, $this->log),
```
- [ ] **Step 4: Run – muss bestehen**
Run: `composer test && composer lint`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/Integrations/Media tests/Unit/Integrations/Media
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: batch media metadata updates and delete with usage check
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 6: Expositions-Policy – direkt / indirekt / deaktiviert je Ability
Aus Etappe 0 offen (a): Welche Abilities direkte Tools, nur über die Meta-Tools erreichbar oder ganz abgeschaltet sind, entscheidet ab jetzt `ExposurePolicy` (Option `lit_mcp_exposure`, gepflegt im Tab „Tools“, Task 10). `Ability::isDirect()` bleibt der Standard. Deaktivierte Abilities werden gar nicht erst bei der Abilities API registriert – damit sind sie weder als Tool noch über `mcp-adapter-execute-ability` erreichbar. `lit/media-preview` liefert Bilder und funktioniert nur direkt; `lit/guide` ist die Einstiegshilfe und lässt sich nicht deaktivieren.
**Files:**
- Create: `src/Abilities/ExposurePolicy.php`
- Modify: `src/Abilities/AbilityRegistrar.php` (Konstruktor, `registerAbilities()`), `src/Server/ServerRegistrar.php` (Konstruktor, `serverArguments()`), `src/Integrations/IntegrationRegistry.php` (`directAbilityNames()` entfernen), `src/Plugin.php` (`boot()`)
- Test: `tests/Unit/Abilities/ExposurePolicyTest.php`, `tests/Unit/Abilities/AbilityRegistrarTest.php` (Test ergänzen), `tests/Unit/Server/ServerRegistrarTest.php` (Test ergänzen), `tests/Unit/Integrations/IntegrationRegistryTest.php` (Assertion entfernen)
**Interfaces:**
- Consumes: `Ability::name()/isDirect()`, `IntegrationRegistry::abilities()`.
- Produces:
- `ExposurePolicy` (final): `OPTION = 'lit_mcp_exposure'`; `DIRECT = 'direct'`, `INDIRECT = 'indirect'`, `DISABLED = 'disabled'`, `MODES`; `DIRECT_ONLY = ['lit/media-preview']`; `REQUIRED = ['lit/guide']`; `__construct(array $overrides = [])`; `static fromOption(): self`; `mode(Ability $a): string`; `defaultMode(Ability $a): string`; `enabled(list<Ability> $abilities): list<Ability>`; `directNames(list<Ability> $abilities): list<string>` (Reihenfolge der Registry); `static isAllowed(string $name, string $mode): bool`; `static sanitize(mixed $input): array<string, string>` (Settings-API-Callback).
- Geänderte Signaturen: `AbilityRegistrar::__construct(IntegrationRegistry $registry, ?ExposurePolicy $policy = null)`; `ServerRegistrar::__construct(IntegrationRegistry $registry, InstructionsBuilder $instructions, ?callable $oauthEnabled = null, ?callable $createAdapterServer = null, ?callable $classExists = null, ?ExposurePolicy $policy = null)` (neuer letzter Parameter; ohne Policy gilt `new ExposurePolicy()` = `isDirect()`).
- Entfernt: `IntegrationRegistry::directAbilityNames()` (einziger Aufrufer war `ServerRegistrar`).
- [ ] **Step 1: Failing Tests**
`tests/Unit/Abilities/ExposurePolicyTest.php`:
```php
<?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;`):
```php
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;`):
```php
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
<?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:
```php
public function __construct(private IntegrationRegistry $registry, private ?ExposurePolicy $policy = null)
{
}
```
```php
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:
```php
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:
```php
'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:
```php
$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**
```bash
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
<?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
<?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:
```php
(new VisibilityFilter())->register();
```
- [ ] **Step 4: Run – muss bestehen**
Run: `composer test && composer lint`
Expected: PASS.
- [ ] **Step 5: Integration – Abonnent sieht nichts, Administrator alles**
Run:
```bash
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**
```bash
git add src/Server/VisibilityFilter.php src/Plugin.php tests/Unit/Server/VisibilityFilterTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: hide tool list and instructions from users without LIT MCP access
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 8: Einstellungsseite (Gerüst) mit Tab „Verbindung“, Admin-Hinweise, Site-Health-Test
Spec §8.4 „Verbindung“: Status Adapter/OAuth, MCP-Server-URL zum Kopieren, Prüfung „Discovery-Dokumente erreichbar, 401 mit `WWW-Authenticate`“. Aus Etappe 0 offen (d): Hinweis, wenn mcp-oauth deaktiviert ist oder `/.well-known/` nicht erreichbar ist. mcp-oauth bringt bereits einen Site-Health-Test für die Discovery-Dokumente mit (`HealthCheck`, Transient `wpmedia_mcp_oauth_wellknown_health_check`); LIT MCP ergänzt einen eigenen Test für den Endpunkt und nutzt das gecachte Discovery-Ergebnis für Admin-Hinweise (keine HTTP-Anfrage bei jedem Seitenaufruf).
**Files:**
- Create: `src/Admin/AdminTab.php`, `src/Admin/SettingsPage.php`, `src/Admin/ConnectionStatus.php`, `src/Admin/ConnectionTab.php`, `src/Admin/AdminNotices.php`, `src/Admin/SiteHealth.php`
- Modify: `src/Plugin.php` (`boot()`, statische Property + `settingsPage()`)
- Test: `tests/Unit/Admin/SettingsPageTest.php`, `tests/Unit/Admin/ConnectionStatusTest.php`, `tests/Unit/Admin/AdminNoticesTest.php`, `tests/Unit/Admin/SiteHealthTest.php`
**Interfaces:**
- Consumes: `ServerRegistrar::NAMESPACE/ROUTE`; `WPMedia\MCP\OAuth\Context::is_enabled()`; `WPMedia\MCP\OAuth\Auth\Discovery\HealthCheck` (`TRANSIENT_KEY`, `run_self_check(): array{label, status, …}`); `WP\MCP\Core\McpAdapter`.
- Produces:
- `AdminTab` (Interface): `slug(): string`, `label(): string`, `registerSettings(): void` (auf `admin_init`), `registerActions(): void` (admin-post-Handler, beim Registrieren der Seite), `render(): void`.
- `SettingsPage` (final): `SLUG = 'lit-mcp'`; `__construct(list<AdminTab> $tabs)` (erster = Standard); `register()`, `addMenu()`, `registerSettings()`, `tabSlugs(): list<string>`, `currentTab(): string`, `render()`; `static url(string $tab = '', array $args = []): string`. Hinweise über `?lit_mcp_notice=revoked|revoke_failed|cleared`.
- `ConnectionStatus` (nicht final): `ENDPOINT_TRANSIENT = 'lit_mcp_endpoint_check'`; `__construct(?callable $oauthEnabled = null)`; `static serverUrl(): string`; `oauthEnabled()`, `adapterLoaded()`, `https(): bool`; `discovery(bool $allowFetch): ?array`; `endpoint(bool $allowFetch): ?array{ok: bool, message: string}` (5 Minuten gecacht); `checks(bool $allowFetch): list<array{label: string, ok: ?bool, message: string}>`.
- `ConnectionTab(ConnectionStatus $status)` – slug `verbindung`.
- `AdminNotices(ConnectionStatus $status)`: `register()`, `messages(bool $onOwnPage): list<string>`, `render()`.
- `SiteHealth(ConnectionStatus $status)`: `TEST = 'lit_mcp_endpoint'`; `register()`, `addTest(mixed $tests): mixed`, `run(): array`.
- `Plugin::settingsPage(): ?SettingsPage` (für den Render-Test in Task 13).
- [ ] **Step 1: Failing Tests**
`tests/Unit/Admin/SettingsPageTest.php`:
```php
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
```php
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
```php
public static function settingsPage(): ?SettingsPage
{
return self::$settingsPage;
}
```
ergänzen und in `boot()` direkt vor `\WPMedia\MCP\OAuth\Bootstrap::instance();` einfügen:
```php
$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**
```bash
git add src/Admin src/Plugin.php tests/Unit/Admin
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: settings page with connection tab, admin notices and site health test
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 9: Tab „Zugriff“ – freigegebene Rollen, aktive Verbindungen, Widerruf
Spec §8.4: erlaubte Rollen (Standard Administrator) und aktive Verbindungen je Benutzer mit Widerruf über die von mcp-oauth erzeugten Anwendungspasswörter. mcp-oauth legt pro Verbindung ein Anwendungspasswort an (Name = Client-Name, `app_id` = Client-ID) und speichert dazu das Benutzer-Meta `mcp_refresh_jti_<uuid>` (`TokenEndpoint::REFRESH_JTI_META_PREFIX`). Nur Anwendungspasswörter mit diesem Meta gelten als LIT-MCP-Verbindung; andere Anwendungspasswörter des Benutzers werden weder angezeigt noch widerrufen. Widerruf = `WP_Application_Passwords::delete_application_password()` – mcp-oauth räumt sein Meta dabei selbst auf (`wp_delete_application_password`) und lehnt das Token ab sofort ab.
**Files:**
- Create: `src/Admin/ConnectionRepository.php`, `src/Admin/AccessTab.php`, `tests/Stubs/WpApplicationPasswords.php`
- Modify: `src/Plugin.php` (`boot()`), `tests/bootstrap.php`
- Test: `tests/Unit/Admin/ConnectionRepositoryTest.php`, `tests/Unit/Admin/AccessTabTest.php`
**Interfaces:**
- Consumes: `Access::OPTION`, `Access::allowedRoles()`; `SettingsPage::url()`, `AdminTab` (Task 8); `WPMedia\MCP\OAuth\Auth\TokenEndpoint::REFRESH_JTI_META_PREFIX`.
- Produces:
- `ConnectionRepository` (nicht final): `__construct(?\wpdb $db = null)`; `all(): list<array{user_id: int, user_login: string, uuid: string, name: string, app_id: string, created: int, last_used: ?int}>`; `revoke(int $userId, string $uuid): bool` (nur mcp-oauth-Verbindungen).
- `AccessTab(Access $access, ConnectionRepository $connections, ?callable $terminate = null)` – slug `zugriff`; `GROUP = 'lit_mcp_access'`, `REVOKE_ACTION = 'lit_mcp_revoke'`; `static sanitizeRoles(mixed $input): list<string>`; `handleRevoke(): void` (admin-post, Nonce `lit_mcp_revoke_<uuid>`, `manage_options`).
- Test-Stub `WP_Application_Passwords` mit statischem Speicher `$items[user_id][uuid]`.
- [ ] **Step 1: Stub**
`tests/Stubs/WpApplicationPasswords.php`:
```php
<?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:
```php
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
<?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
<?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
<?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
<?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
```php
$access = new Access();
$access->register();
```
und die Tab-Liste ersetzen durch
```php
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**
```bash
git add src/Admin src/Plugin.php tests/Stubs/WpApplicationPasswords.php tests/bootstrap.php tests/Unit/Admin
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: access tab with allowed roles and connection revocation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 10: Tabs „Tools“ (Exposition je Ability) und „Limits“
Spec §8.4: „direkt / indirekt / deaktiviert je Ability“ und „Upload-Größe, Protokoll-Aufbewahrung, Anzahl Snapshots“. Die Auswahl „Standard“ speichert nichts – so wirkt ein geänderter Standard einer späteren Version automatisch. Unmögliche Kombinationen (`lit/media-preview` indirekt, `lit/guide` deaktiviert) werden gar nicht angeboten und von `ExposurePolicy::sanitize()` zusätzlich verworfen. Für das Divi-Code-Modul gibt es keine Einstellung.
**Files:**
- Create: `src/Admin/ToolsTab.php`, `src/Admin/LimitsTab.php`
- Modify: `src/Plugin.php` (Tab-Liste)
- Test: `tests/Unit/Admin/ToolsTabTest.php`, `tests/Unit/Admin/LimitsTabTest.php`
**Interfaces:**
- Consumes: `ExposurePolicy::OPTION/MODES/mode()/defaultMode()/isAllowed()/sanitize()` (Task 6), `IntegrationRegistry::abilities()`, `Limits::OPTION/DEFAULTS/all()/sanitize()` (1a), `AdminTab`.
- Produces: `ToolsTab(IntegrationRegistry $registry, ExposurePolicy $policy)` – slug `tools`, `GROUP = 'lit_mcp_tools'`; `LimitsTab()` – slug `limits`, `GROUP = 'lit_mcp_limits'`.
- [ ] **Step 1: Failing Tests**
`tests/Unit/Admin/ToolsTabTest.php`:
```php
<?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
<?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
<?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
<?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
```php
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**
```bash
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)` – slug `protokoll`, `CLEAR_ACTION = 'lit_mcp_clear_log'`; `filters(): array{ability: string, user_id: int}`; `handleClear(): void`.
- `LogListTable extends \WP_List_Table` (final): `__construct(ChangeLog $log, array $filters)`; Spalten Zeit (UTC), Benutzer, Client, Tool, Objekt (Bearbeiten-Link bei Beiträgen), Zusammenfassung, Revision (Link), Ergebnis; 50 Einträge je Seite.
- [ ] **Step 1: Failing Tests**
In `tests/Unit/ChangeLog/ChangeLogTest.php` ergänzen:
```php
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
<?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:
```php
/**
* @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
<?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
<?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:
```php
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**
```bash
git add src/Admin src/ChangeLog/ChangeLog.php src/Plugin.php tests/Unit/Admin tests/Unit/ChangeLog/ChangeLogTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: change log tab with filters, revision links and clear button
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 12: Aufräumen – Deaktivierung und Deinstallation
Aus Etappe 0 offen (c). Deaktivieren entfernt den Cron-Job und den Prüf-Transient und verwirft die gespeicherten Rewrite-Regeln, damit WordPress sie beim nächsten Aufruf ohne die `/oauth/…`- und `/.well-known/…`-Regeln des nun inaktiven Plugins neu erzeugt (ein `flush_rewrite_rules()` während der Deaktivierung würde sie wieder mitschreiben, weil das Plugin in diesem Request noch geladen ist). Aktivieren stößt über mcp-oauths öffentliche API `Bootstrap::schedule_rewrite_flush()` das Neuschreiben seiner Regeln an. Löschen entfernt alle eigenen Daten, auf Multisite je Website – und bewusst **nicht** die Daten von mcp-oauth (siehe Global Constraints; dokumentiert in `readme.txt`, Task 13).
**Files:**
- Create: `uninstall.php`, `tests/Unit/UninstallTest.php`
- Modify: `src/Lifecycle.php` (komplett ersetzen), `phpcs.xml.dist` (Datei ergänzen), `tests/Unit/LifecycleTest.php` (komplett ersetzen)
**Interfaces:**
- Consumes: `ChangeLog`, `ChangeLogInstaller::VERSION_OPTION`, `ChangeLogCleanup::HOOK`, `Limits::OPTION`, `Access::OPTION`, `ExposurePolicy::OPTION`, `ConnectionStatus::ENDPOINT_TRANSIENT`; `WPMedia\MCP\OAuth\Bootstrap::schedule_rewrite_flush()`.
- Produces: `Lifecycle::activate()` (zusätzlich Rewrite-Flush von mcp-oauth anstoßen), `Lifecycle::deactivate()` (Cron, Transient, `rewrite_rules`); `uninstall.php` (Optionen `lit_mcp_allowed_roles`, `lit_mcp_exposure`, `lit_mcp_limits`, `lit_mcp_db_version`, Transient `lit_mcp_endpoint_check`, Cron `lit_mcp_log_cleanup`, Tabelle `{prefix}lit_mcp_log`).
- [ ] **Step 1: Failing Tests**
`tests/Unit/LifecycleTest.php` (komplett ersetzen):
```php
<?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
<?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
<?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
<?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:
```bash
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**
```bash
git add uninstall.php src/Lifecycle.php phpcs.xml.dist tests/Unit/LifecycleTest.php tests/Unit/UninstallTest.php
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "feat: clean up own data on deactivation and uninstall
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
---
### Task 13: Dokumentation, Version 0.2.0, Ende-zu-Ende-Test, manuelle Abnahme
Aus Etappe 0 offen (g): Auswirkungen auf die gesamte Website dokumentieren – als `readme.txt` (WordPress-Standard, zeigt Administratoren auch „Tested up to“). Dazu die Medien-Anleitung für Claude, die Versionsnummer und der Ende-zu-Ende-Test aller Tools und Admin-Funktionen dieses Plans.
**Files:**
- Create: `readme.txt`, `docs/guides/media.md`, `tests/Integration/admin-render.php`, `tests/Integration/media-admin-smoke.sh`
- Modify: `docs/guides/overview.md` (Abschnitt „Werkzeuge“), `wp-lit-mcp.php` (Version), `src/Plugin.php` (`VERSION`)
**Interfaces:**
- Consumes: alle Abilities aus 1a/1b, `Plugin::settingsPage()` und `SettingsPage::tabSlugs()/render()` (Task 8), `tests/Integration/lib.sh` (1a), `mint-token.php`.
- Produces: `readme.txt`; `admin-render.php` (gibt je Tab `<slug> ok <bytes>` oder `<slug> FAIL …` aus); `media-admin-smoke.sh`.
- [ ] **Step 1: Readme mit Auswirkungen auf die Website**
`readme.txt`:
```
=== WP-LIT-MCP ===
Contributors: loosenit
Tags: mcp, claude, divi, pods, ai
Requires at least: 6.9
Tested up to: 7.0
Requires PHP: 8.1
Stable tag: 0.2.0
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai.
== Description ==
WP-LIT-MCP stellt einen MCP-Server bereit, über den Claude (claude.ai-Connector) diese Website pflegt:
Inhalte aller Inhaltstypen inkl. Pods-Feldern, Status, Kopien, Revisionen, Menüs, freigegebene
WordPress-Einstellungen und die Mediathek. Jede Änderung läuft mit den Rechten des verbundenen
WordPress-Benutzers, wird protokolliert und ist über Revisionen umkehrbar.
Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, Limits).
== Auswirkungen auf die gesamte Website ==
* Der Standard-MCP-Server von wordpress/mcp-adapter (REST-Route /wp-json/mcp/mcp-adapter-default-server)
wird abgeschaltet (Filter mcp_adapter_create_default_server). Andere Plugins, die diesen ungeschützten
Server erwarten, finden ihn nicht mehr.
* Es gibt genau einen MCP-Endpunkt: /wp-json/mcp/mcp-oauth-server (OAuth 2.1 über die eingebundene
Bibliothek wp-media/mcp-oauth). Anfragen ohne gültiges Token erhalten 401 mit WWW-Authenticate.
* mcp-oauth registriert Rewrite-Regeln für /oauth/… und /.well-known/oauth-… sowie einen
Website-Zustand-Test. Der Webserver muss /.well-known/ an WordPress durchreichen (bei nginx-Regeln,
die Punkt-Pfade sperren, eine Ausnahme für /.well-known/ anlegen).
* Jede Verbindung ist ein Anwendungspasswort des Benutzers (Name = Client, z. B. „Claude“). Es ist im
Benutzerprofil sichtbar und unter Einstellungen → LIT MCP → Zugriff widerrufbar.
* Die Capability lit_mcp_use wird zur Laufzeit an die freigegebenen Rollen vergeben; Rollen in der
Datenbank werden nicht verändert.
* Eigene Daten: Optionen lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version,
Tabelle {prefix}lit_mcp_log (nur Verweise, keine Inhalte), täglicher Cron-Job lit_mcp_log_cleanup,
Transient lit_mcp_endpoint_check.
* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar
(Standard 5 MB); Import per URL nur von öffentlich erreichbaren Adressen.
== Deaktivieren und Löschen ==
* Deaktivieren entfernt den Cron-Job und lässt WordPress die Rewrite-Regeln neu erzeugen; der
MCP-Endpunkt und die OAuth-Adressen sind danach nicht mehr erreichbar. Einstellungen und Protokoll
bleiben erhalten.
* Löschen entfernt zusätzlich alle eigenen Optionen und die Protokolltabelle (auf Multisite je Website).
* Nicht entfernt werden die Daten von mcp-oauth (Option mcp_jwt_secret, Option
wpmedia_mcp_oauth_rewrite_version, Benutzer-Meta mcp_refresh_jti_*, Anwendungspasswörter der
Verbindungen), weil diese Bibliothek auch von anderen Plugins genutzt werden kann. Verbindungen
deshalb vor dem Löschen unter Einstellungen → LIT MCP → Zugriff widerrufen; nicht mehr benötigte
Anwendungspasswörter lassen sich im Benutzerprofil entfernen.
== Multisite ==
Das Plugin wird je Website aktiviert und konfiguriert; Rollenfreigaben, Tool-Einstellungen und
Protokoll gelten pro Website.
== Changelog ==
= 0.2.0 =
* WordPress-Basis: Schema-Erkennung inkl. Pods, Inhalte lesen/anlegen/ändern, Status, Kopie und
Austausch, Revisionen, Menüs, WordPress-Einstellungen, Mediathek, Änderungsprotokoll,
Einstellungsseite, Tool-Exposition, Aufräumen bei Deaktivierung/Deinstallation.
= 0.1.0 =
* Anbindung: OAuth-geschützter MCP-Server, Rollen-Gate, lit-guide, lit-site-status.
```
- [ ] **Step 2: Anleitungen und Version**
`docs/guides/media.md`:
```markdown
# 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:
```markdown
## 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
<?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`:
```bash
#!/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:
```bash
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**
```bash
git add readme.txt docs/guides wp-lit-mcp.php src/Plugin.php tests/Integration/admin-render.php tests/Integration/media-admin-smoke.sh
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "docs: readme with site-wide effects, media guide; test: media/admin end-to-end; version 0.2.0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
```
- [ ] **Step 7: Manuelle Abnahme (mit Jan-Niclas, Browser)**
Der ausführende Agent bereitet vor und meldet; die Punkte prüft Jan-Niclas selbst:
1. Einstellungen → LIT MCP: alle fünf Tabs öffnen; Rolle „Redakteur“ freigeben und wieder entfernen; im Tab „Tools“ ein Tool auf „indirekt“ stellen, speichern, zurück auf „Standard“.
2. claude.ai-Connector verbinden (Staging oder `ddev share`), die Verbindung erscheint unter „Zugriff“; „Widerrufen“ → der nächste Tool-Aufruf in claude.ai scheitert mit 401 und verlangt eine neue Anmeldung.
3. In claude.ai: „Suche Bilder ohne Alt-Text, zeig mir eins und schlag einen Alt-Text vor“ – `lit-media-preview` liefert ein sichtbares Bild.
4. Werkzeuge → Website-Zustand: Test „LIT MCP Endpunkt“ ist grün (bzw. zeigt verständlich, was fehlt).
5. Protokoll-Tab: Einträge der Tests sichtbar, Filter nach Tool funktioniert, Revisions-Link öffnet den WordPress-Revisionsvergleich, „Protokoll leeren“ leert die Liste.
Ergebnisse als kurze Notiz an den Ausführenden zurückmelden; Abweichungen werden als Folgeaufgaben erfasst.
---
## Self-Review (durchgeführt beim Schreiben)
- **Spec-Abdeckung (Teil 1b):** §6.3 Medien komplett (search inkl. `missing_alt`/`unused`, get mit Verwendungsorten, preview als `ImageContent` ohne `outputSchema`, upload-from-url mit `wp_safe_remote_get`/Whitelist/Limit, Base64-Upload mit einstellbarem Limit, download als Blob-Ressource, Stapel-Update, delete mit `confirm`/`force`) → Tasks 1–5; §4.3 Exposition direkt/indirekt/deaktiviert → Task 6 + Tab in Task 10; §8.3 Admin-Ansicht, Filter, Revisions-Links, Leeren → Task 11; §8.4 Verbindung (URL, Status, Site-Health), Zugriff (Rollen, Verbindungen, Widerruf), Tools, Limits → Tasks 8–10; §10 HTTPS-Hinweis, SSRF-Schutz, MIME-Whitelist, Größenlimits → Tasks 4, 8. Etappe-0-Rückstände: (a) → Task 6, (b) → Task 7, (c) → Task 12, (d) → Task 8, (g) → Task 13; (e)/(f) erledigt in 1a.
- **Bewusste Entscheidungen:** Discovery-Prüfung nutzt den vorhandenen Site-Health-Test von mcp-oauth, LIT MCP ergänzt nur den Endpunkt-Test; Admin-Hinweise lesen nur gecachte Ergebnisse. Deaktivierte Abilities werden gar nicht registriert. `lit/media-preview` kann nicht indirekt, `lit/guide` nicht deaktiviert werden. Medien ohne aktivierten `MEDIA_TRASH` werden (WordPress-Verhalten) endgültig gelöscht – nur nach `confirm` und Verwendungsprüfung. Build mit `composer install --no-dev` (Ruling 12 aus Etappe 0) gehört zur Etappe U und ist hier nicht enthalten.
- **Typ-Konsistenz geprüft:** `ExposurePolicy`-Methoden in Registrar/Server/ToolsTab, `ConnectionStatus`-Methoden in Tab/Notices/SiteHealth, `ChangeLog::query/abilities/clear` in LogTab/LogListTable, `MediaUsageFinder::usages/isUsed` in get/search/delete, `MediaImporter::fromUrl/fromBase64` in beiden Upload-Tools stimmen mit den Interfaces-Blöcken überein.