From bca35f6166e3cf430fd9ad3b58197d0dc0e91ed9 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 03:46:47 +0200 Subject: [PATCH] feat: tools and limits settings tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Einstellungen → LIT MCP → Tools (direct/indirect/disabled per ability, only ExposurePolicy-allowed modes offered) and → Limits (upload size, log retention, snapshot count), wired into Plugin.php. Ruling Q11b (carried from task 6): overview.md notes that direct visibility is configurable; hints in src/ that name a tool which is direct by default but can be switched to indirect (content-get, content-query, content-types, post-publish, media-search) now use the neutral "lit/ (direkt: lit-)" form so they stay correct after an admin override. lit/guide (always direct) and the media-preview/-download DIRECT_ONLY tools are unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 3 + src/Abilities/Errors.php | 14 ++- src/Admin/LimitsTab.php | 69 ++++++++++ src/Admin/ToolsTab.php | 119 ++++++++++++++++++ src/Guides/InstructionsBuilder.php | 2 +- src/Integrations/Media/MediaGuard.php | 2 +- src/Integrations/Media/MediaUpdateAbility.php | 13 +- src/Integrations/WordPress/PostGuard.php | 6 +- .../Status/PostReplaceWithCopyAbility.php | 4 +- .../WordPress/Status/PostScheduleAbility.php | 2 +- .../WordPress/Status/StatusWrite.php | 4 +- src/Plugin.php | 4 + tests/Unit/Admin/LimitsTabTest.php | 43 +++++++ tests/Unit/Admin/ToolsTabTest.php | 67 ++++++++++ 14 files changed, 330 insertions(+), 22 deletions(-) create mode 100644 src/Admin/LimitsTab.php create mode 100644 src/Admin/ToolsTab.php create mode 100644 tests/Unit/Admin/LimitsTabTest.php create mode 100644 tests/Unit/Admin/ToolsTabTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index b54f0f6..8baa106 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -21,6 +21,9 @@ Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-conte `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, `lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`. +Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei +`lit-guide`, das immer direkt bleibt). + Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und Hinweisen immer so geschrieben): Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`, diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php index 52576bf..0624abd 100644 --- a/src/Abilities/Errors.php +++ b/src/Abilities/Errors.php @@ -50,7 +50,8 @@ final class Errors public static function notFound( string $message, - string $hint = 'ID prüfen, z. B. über lit-content-query oder lit-media-search suchen.' + string $hint = 'ID prüfen, z. B. über lit/content-query (direkt: lit-content-query) oder lit/media-search ' + . '(direkt: lit-media-search) suchen.' ): WP_Error { return self::make('not_found', $message, $hint, 404); } @@ -60,7 +61,8 @@ final class Errors return self::make( 'rev_mismatch', 'Der Inhalt wurde seit dem letzten Lesen geändert; nichts wurde geschrieben.', - 'Neu lesen (z. B. lit-content-get) und die Änderung auf dem aktuellen Stand wiederholen.', + 'Neu lesen (z. B. lit/content-get, direkt: lit-content-get) und die Änderung auf dem aktuellen Stand ' + . 'wiederholen.', 409 ); } @@ -144,10 +146,10 @@ final class Errors 'partial_write', trim('Speichern fehlgeschlagen: ' . $reason) . ' Die Änderung konnte nicht vollständig zurückgenommen ' . 'werden. ' . $state, - $hint ?? 'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand prüfen (lit-content-get, bei ' - . 'einem neuen Entwurf lit-content-query) und ggf. mit lit/revisions-list und lit/revision-restore ' - . '(über mcp-adapter-execute-ability) den vorherigen Stand wiederherstellen; dem Nutzer Bescheid ' - . 'geben.', + $hint ?? 'Den Aufruf nicht einfach wiederholen. Zuerst den aktuellen Stand prüfen (lit/content-get, ' + . 'direkt: lit-content-get; bei einem neuen Entwurf lit/content-query, direkt: lit-content-query) ' + . 'und ggf. mit lit/revisions-list und lit/revision-restore (über mcp-adapter-execute-ability) den ' + . 'vorherigen Stand wiederherstellen; dem Nutzer Bescheid geben.', 500, $details ); diff --git a/src/Admin/LimitsTab.php b/src/Admin/LimitsTab.php new file mode 100644 index 0000000..cdb029f --- /dev/null +++ b/src/Admin/LimitsTab.php @@ -0,0 +1,69 @@ + '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 '
'; + settings_fields(self::GROUP); + echo ''; + foreach ($fields as $key => [$label, $min, $max]) { + printf( + '', + esc_attr($key), + esc_html($label), + esc_attr((string) $min), + esc_attr((string) $max), + esc_attr(Limits::OPTION), + esc_attr((string) $values[$key]) + ); + } + echo '
' + . '' + . '
'; + submit_button(); + echo '
'; + } +} diff --git a/src/Admin/ToolsTab.php b/src/Admin/ToolsTab.php new file mode 100644 index 0000000..00b7aa4 --- /dev/null +++ b/src/Admin/ToolsTab.php @@ -0,0 +1,119 @@ + 'array', + 'sanitize_callback' => [ExposurePolicy::class, 'sanitize'], + 'default' => [], + ]); + } + + public function registerActions(): void + { + } + + public function render(): void + { + echo '

' + . esc_html__('Direkt: sofort als eigenes Tool sichtbar.', 'wp-lit-mcp') . ' ' + . esc_html__('Indirekt: nur über die Meta-Tools (discover/execute) erreichbar.', 'wp-lit-mcp') . ' ' + . esc_html__('Deaktiviert: gar nicht verfügbar.', 'wp-lit-mcp') + . '

'; + echo '

' + . esc_html__( + 'Die Server-Anweisungen beim Verbinden nennen immer die aktuell direkt sichtbaren Tools.', + 'wp-lit-mcp' + ) . ' ' + . esc_html__( + 'Änderungen gelten also ab der nächsten Verbindung; in claude.ai ggf. den Connector neu verbinden.', + 'wp-lit-mcp' + ) + . '

'; + echo '

' . esc_html__( + 'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.', + 'wp-lit-mcp' + ) . '

'; + echo '
'; + settings_fields(self::GROUP); + echo ''; + + foreach ($this->registry->abilities() as $ability) { + $name = $ability->name(); + $current = $this->policy->mode($ability); + $isDefault = $current === $this->policy->defaultMode($ability); + printf( + ''; + } + + echo '
' . esc_html__('Tool', 'wp-lit-mcp') . '' + . esc_html__('Einstellung', 'wp-lit-mcp') . '
%s
%s
'; + submit_button(); + echo '
'; + } + + 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'), + }; + } +} diff --git a/src/Guides/InstructionsBuilder.php b/src/Guides/InstructionsBuilder.php index 8d092f3..81f830f 100644 --- a/src/Guides/InstructionsBuilder.php +++ b/src/Guides/InstructionsBuilder.php @@ -49,7 +49,7 @@ final class InstructionsBuilder if ($this->postTypeNames !== null) { $lines[] = 'Inhaltstypen: ' . implode(', ', ($this->postTypeNames)()) - . ' (Felder und Taxonomien: lit-content-types).'; + . ' (Felder und Taxonomien: lit/content-types, direkt: lit-content-types).'; } return implode("\n", array_merge($lines, [ diff --git a/src/Integrations/Media/MediaGuard.php b/src/Integrations/Media/MediaGuard.php index 51771fd..e8c2788 100644 --- a/src/Integrations/Media/MediaGuard.php +++ b/src/Integrations/Media/MediaGuard.php @@ -35,7 +35,7 @@ final class MediaGuard if (! $attachment instanceof WP_Post || $attachment->post_type !== 'attachment') { return Errors::notFound( sprintf('Medium %d nicht gefunden.', $attachmentId), - 'ID prüfen; Medien über lit-media-search suchen.' + 'ID prüfen; Medien über lit/media-search (direkt: lit-media-search) suchen.' ); } diff --git a/src/Integrations/Media/MediaUpdateAbility.php b/src/Integrations/Media/MediaUpdateAbility.php index 896a57a..7b677cd 100644 --- a/src/Integrations/Media/MediaUpdateAbility.php +++ b/src/Integrations/Media/MediaUpdateAbility.php @@ -34,8 +34,8 @@ final class MediaUpdateAbility implements Ability private const REASON = 'WordPress hat die Metadaten eines Mediums nicht gespeichert.'; - private const REV_HINT = 'Die genannten Medien neu lesen (lit/media-get oder lit-media-search) und mit dem ' - . 'aktuellen modified erneut senden.'; + private const REV_HINT = 'Die genannten Medien neu lesen (lit/media-get oder lit/media-search, direkt: ' + . 'lit-media-search) und mit dem aktuellen modified erneut senden.'; public function __construct(private ChangeLog $log, private MediaMetaWriter $writer = new MediaMetaWriter()) { @@ -284,8 +284,9 @@ final class MediaUpdateAbility implements Ability $cause, $detail, rolledBackHint: 'Das Zurücksetzen hat modified der Medien geändert: vor einem neuen Versuch die Medien ' - . 'neu lesen (lit/media-get oder lit-media-search) und den Stapel mit dem aktuellen modified ' - . 'senden; tritt der Fehler wieder auf, den Administrator informieren (Details im PHP-Error-Log).' + . 'neu lesen (lit/media-get oder lit/media-search, direkt: lit-media-search) und den Stapel mit ' + . 'dem aktuellen modified senden; tritt der Fehler wieder auf, den Administrator informieren ' + . '(Details im PHP-Error-Log).' ); } @@ -399,8 +400,8 @@ final class MediaUpdateAbility implements Ability return match (count($codes) === 1 ? $codes[0] : 'mixed') { 'forbidden' => Errors::make('forbidden', $message, 'Nur Medien ändern, die das verbundene Konto ' . 'bearbeiten darf; die übrigen Einträge erneut senden.', 403, ['failures' => $failures]), - 'not_found' => Errors::make('not_found', $message, 'IDs prüfen (lit-media-search) und die Einträge ' - . 'korrigiert erneut senden.', 404, ['failures' => $failures]), + 'not_found' => Errors::make('not_found', $message, 'IDs prüfen (lit/media-search, direkt: ' + . 'lit-media-search) und die Einträge korrigiert erneut senden.', 404, ['failures' => $failures]), 'rev_mismatch' => Errors::make('rev_mismatch', $message, self::REV_HINT, 409, ['failures' => $failures]), default => Errors::make('validation_failed', $message, 'Die unter failures genannten Einträge ' . 'korrigieren und den ganzen Stapel erneut senden.', 400, ['failures' => $failures]), diff --git a/src/Integrations/WordPress/PostGuard.php b/src/Integrations/WordPress/PostGuard.php index b711ad9..2d17de2 100644 --- a/src/Integrations/WordPress/PostGuard.php +++ b/src/Integrations/WordPress/PostGuard.php @@ -88,8 +88,8 @@ final class PostGuard (int) $post->ID ), [$field => 'geplanter Zeitpunkt verstrichen'], - 'Beim Nutzer nachfragen: jetzt veröffentlichen (lit-post-publish) oder neu planen (lit/post-schedule ' - . 'über mcp-adapter-execute-ability); danach die Änderung wiederholen.' + 'Beim Nutzer nachfragen: jetzt veröffentlichen (lit/post-publish, direkt: lit-post-publish) oder neu ' + . 'planen (lit/post-schedule über mcp-adapter-execute-ability); danach die Änderung wiederholen.' ); } @@ -107,7 +107,7 @@ final class PostGuard if (! is_object($object)) { return Errors::notFound( sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name), - 'Verfügbare Inhaltstypen über lit-content-types abrufen.' + 'Verfügbare Inhaltstypen über lit/content-types (direkt: lit-content-types) abrufen.' ); } diff --git a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php index 268a8fb..f59db22 100644 --- a/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php +++ b/src/Integrations/WordPress/Status/PostReplaceWithCopyAbility.php @@ -289,8 +289,8 @@ final class PostReplaceWithCopyAbility implements Ability $revisionId === null ? '–' : (string) $revisionId, $backupId ), - 'Den Aufruf nicht einfach wiederholen. Zuerst den Stand mit lit-content-get prüfen; Titel, Inhalt ' - . 'und Auszug lassen sich mit lit/revision-restore (revision_id, über ' + 'Den Aufruf nicht einfach wiederholen. Zuerst den Stand mit lit/content-get (direkt: lit-content-get) ' + . 'prüfen; Titel, Inhalt und Auszug lassen sich mit lit/revision-restore (revision_id, über ' . 'mcp-adapter-execute-ability) zurückholen, Felder/Meta und Begriffe stehen in der Sicherung ' . 'backup_id (in WordPress aus dem Papierkorb holen). Dem Nutzer Bescheid geben.', $details diff --git a/src/Integrations/WordPress/Status/PostScheduleAbility.php b/src/Integrations/WordPress/Status/PostScheduleAbility.php index 0f1048a..4bd7bbb 100644 --- a/src/Integrations/WordPress/Status/PostScheduleAbility.php +++ b/src/Integrations/WordPress/Status/PostScheduleAbility.php @@ -103,7 +103,7 @@ final class PostScheduleAbility implements Ability return Errors::invalidInput( 'Der Zeitpunkt muss in der Zukunft liegen.', ['date' => 'liegt nicht in der Zukunft'], - 'Zum sofortigen Veröffentlichen lit-post-publish verwenden.' + 'Zum sofortigen Veröffentlichen lit/post-publish (direkt: lit-post-publish) verwenden.' ); } diff --git a/src/Integrations/WordPress/Status/StatusWrite.php b/src/Integrations/WordPress/Status/StatusWrite.php index 4f30e0a..c7afc40 100644 --- a/src/Integrations/WordPress/Status/StatusWrite.php +++ b/src/Integrations/WordPress/Status/StatusWrite.php @@ -41,8 +41,8 @@ final class StatusWrite $e, '', sprintf('Status von Eintrag %d ist möglicherweise schon geändert worden.', $postId), - 'Den Aufruf nicht einfach wiederholen. Zuerst mit lit-content-get Status und Inhalt prüfen und dem ' - . 'Nutzer Bescheid geben.' + 'Den Aufruf nicht einfach wiederholen. Zuerst mit lit/content-get (direkt: lit-content-get) Status ' + . 'und Inhalt prüfen und dem Nutzer Bescheid geben.' ); } } diff --git a/src/Plugin.php b/src/Plugin.php index bcf72b1..c6982c4 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -11,8 +11,10 @@ use LIT\MCP\Admin\AdminNotices; use LIT\MCP\Admin\ConnectionRepository; use LIT\MCP\Admin\ConnectionStatus; use LIT\MCP\Admin\ConnectionTab; +use LIT\MCP\Admin\LimitsTab; use LIT\MCP\Admin\SettingsPage; use LIT\MCP\Admin\SiteHealth; +use LIT\MCP\Admin\ToolsTab; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; @@ -82,6 +84,8 @@ final class Plugin self::$settingsPage = new SettingsPage([ new ConnectionTab($status), new AccessTab($access, new ConnectionRepository()), + new ToolsTab($registry, $policy), + new LimitsTab(), ]); if (is_admin()) { self::$settingsPage->register(); diff --git a/tests/Unit/Admin/LimitsTabTest.php b/tests/Unit/Admin/LimitsTabTest.php new file mode 100644 index 0000000..afd36b1 --- /dev/null +++ b/tests/Unit/Admin/LimitsTabTest.php @@ -0,0 +1,43 @@ +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); + } +} diff --git a/tests/Unit/Admin/ToolsTabTest.php b/tests/Unit/Admin/ToolsTabTest.php new file mode 100644 index 0000000..9ec0249 --- /dev/null +++ b/tests/Unit/Admin/ToolsTabTest.php @@ -0,0 +1,67 @@ +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('