Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
6083 lines
228 KiB
Markdown
6083 lines
228 KiB
Markdown
# 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.
|