feat: tools and limits settings tabs

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/<name> (direkt: lit-<name>)" 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 03:47:40 +02:00
co-authored by Claude Opus 5.5
parent eee1840411
commit bca35f6166
14 changed files with 330 additions and 22 deletions
+3
View File
@@ -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`,
+8 -6
View File
@@ -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
);
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use LIT\MCP\Limits;
/**
* Tab "Limits": upload/download size, log retention, snapshot count (spec §8.4).
* Changes apply immediately – Limits::all() always reads the current option.
*/
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),
esc_attr((string) $min),
esc_attr((string) $max),
esc_attr(Limits::OPTION),
esc_attr((string) $values[$key])
);
}
echo '</tbody></table>';
submit_button();
echo '</form>';
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Admin;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Integrations\IntegrationRegistry;
/**
* Tab "Tools": direct/indirect/disabled exposure per ability (spec §8.4). Only the modes
* ExposurePolicy::isAllowed() allows for a given ability are offered; "Standard" saves nothing,
* so a later version's changed default takes effect automatically.
*/
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.', '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')
. '</p>';
echo '<p>'
. 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'
)
. '</p>';
echo '<p>' . esc_html__(
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
'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'),
};
}
}
+1 -1
View File
@@ -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, [
+1 -1
View File
@@ -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.'
);
}
@@ -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]),
+3 -3
View File
@@ -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.'
);
}
@@ -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
@@ -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.'
);
}
@@ -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.'
);
}
}
+4
View File
@@ -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();
+43
View File
@@ -0,0 +1,43 @@
<?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);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?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);
}
}