feat: group tools by integration with inherited group mode and "nur lesen" switch

Tools tab shows one block per integration with a group mode (inherited by its
tools) and a read-only switch that disables all non-readonly abilities of the
group. ExposurePolicy resolves tool override > group override > tool default;
lit/guide keeps its default, direct-only tools fall back to direct under a
group "indirekt". Group entries live in lit_mcp_exposure under "@group:<id>"
and "@readonly:<id>"; the old flat format stays valid. The ability->group map
comes lazily from IntegrationRegistry (Divi is not loaded at plugins_loaded).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 16:17:05 +02:00
co-authored by Claude Opus 5.5
parent ede0221b50
commit 776dbf8f4a
10 changed files with 847 additions and 92 deletions
+156 -1
View File
@@ -12,11 +12,15 @@ use Mockery;
final class ExposurePolicyTest extends TestCase
{
private function ability(string $name, bool $direct): Ability
/** @param array<string, bool>|null $annotations */
private function ability(string $name, bool $direct, ?array $annotations = null): Ability
{
$ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn($name);
$ability->allows('isDirect')->andReturn($direct);
if ($annotations !== null) {
$ability->allows('annotations')->andReturn($annotations);
}
return $ability;
}
@@ -113,4 +117,155 @@ final class ExposurePolicyTest extends TestCase
self::assertSame('direct', ExposurePolicy::fromOption()->mode($this->ability('lit/a', true)));
}
private const GROUPS = [
'lit/guide' => 'core',
'lit/site-status' => 'core',
'lit/divi-outline' => 'divi',
'lit/divi-update' => 'divi',
'lit/divi-delete' => 'divi',
'lit/media-preview' => 'media',
'lit/media-download' => 'media',
'lit/media-get' => 'media',
];
public function testToolOverrideBeatsGroupOverrideBeatsToolDefault(): void
{
$policy = (new ExposurePolicy([
'@group:divi' => 'indirect',
'lit/divi-update' => 'direct',
]))->withGroups(self::GROUPS);
self::assertSame('direct', $policy->mode($this->ability('lit/divi-update', false)));
self::assertSame('indirect', $policy->mode($this->ability('lit/divi-outline', true)));
self::assertSame('direct', $policy->mode($this->ability('lit/site-status', true)));
self::assertTrue($policy->hasToolOverride('lit/divi-update'));
self::assertFalse($policy->hasToolOverride('lit/divi-outline'));
self::assertSame('indirect', $policy->groupMode('divi'));
self::assertNull($policy->groupMode('core'));
self::assertSame('indirect', $policy->inheritedMode($this->ability('lit/divi-update', false)));
}
public function testGroupSettingsAreIgnoredWithoutAGroupMap(): void
{
$policy = new ExposurePolicy(['@group:divi' => 'disabled', '@readonly:divi' => true]);
self::assertSame('direct', $policy->mode($this->ability('lit/divi-outline', true)));
}
public function testGuideKeepsItsDefaultWhateverTheGroupSays(): void
{
$policy = (new ExposurePolicy(['@group:core' => 'disabled', '@readonly:core' => true]))
->withGroups(self::GROUPS);
self::assertSame('direct', $policy->mode($this->ability('lit/guide', true, [])));
self::assertSame('disabled', $policy->mode($this->ability('lit/site-status', true, ['readonly' => true])));
}
public function testDirectOnlyToolsTakeTheNearestAllowedModeWhenTheGroupSaysIndirect(): void
{
$policy = (new ExposurePolicy(['@group:media' => 'indirect', 'lit/media-download' => 'disabled']))
->withGroups(self::GROUPS);
$preview = $this->ability('lit/media-preview', true);
self::assertSame('direct', $policy->mode($preview));
self::assertTrue($policy->groupModeAdjusted($preview));
self::assertSame('disabled', $policy->mode($this->ability('lit/media-download', true)));
self::assertSame('indirect', $policy->mode($this->ability('lit/media-get', true)));
self::assertFalse($policy->groupModeAdjusted($this->ability('lit/media-get', true)));
}
public function testReadonlyGroupDisablesWriteToolsEvenAgainstAToolOverride(): void
{
$policy = (new ExposurePolicy([
'@readonly:divi' => true,
'@group:divi' => 'direct',
'lit/divi-update' => 'direct',
'lit/divi-outline' => 'indirect',
]))->withGroups(self::GROUPS);
$update = $this->ability('lit/divi-update', false, ['readonly' => false, 'destructive' => false]);
$delete = $this->ability('lit/divi-delete', false, ['destructive' => true]);
$outline = $this->ability('lit/divi-outline', true, ['readonly' => true]);
self::assertSame('disabled', $policy->mode($update));
self::assertSame('disabled', $policy->mode($delete));
self::assertTrue($policy->disabledByReadonly($update));
self::assertSame('indirect', $policy->mode($outline));
self::assertFalse($policy->disabledByReadonly($outline));
self::assertTrue($policy->isReadonly('divi'));
self::assertFalse($policy->isReadonly('media'));
self::assertSame([$outline], $policy->enabled([$update, $delete, $outline]));
self::assertSame([], $policy->directNames([$update, $delete, $outline]));
}
public function testSanitizeValidatesGroupEntries(): void
{
self::assertSame(
[
'@group:divi' => 'indirect',
'@readonly:divi' => true,
'@readonly:media' => true,
'lit/menu-list' => 'disabled',
],
ExposurePolicy::sanitize([
'lit/menu-list' => 'disabled',
'@group:divi' => 'indirect',
'@group:media' => 'sometimes',
'@group:core' => '',
'@group:unknown' => 'direct',
'@group:' => 'direct',
'@group:<b>' => 'direct',
'@readonly:divi' => '1',
'@readonly:media' => true,
'@readonly:core' => 'yes',
'@readonly:pods' => ['1'],
'@readonly:unknown' => '1',
'@other:divi' => 'direct',
'@group:divi/x' => 'direct',
0 => 'direct',
], ['core', 'divi', 'media', 'pods'])
);
}
public function testSanitizeWithoutKnownGroupsStillChecksTheIdSyntax(): void
{
self::assertSame(
['@group:divi' => 'disabled', '@readonly:divi' => true],
ExposurePolicy::sanitize(['@group:divi' => 'disabled', '@readonly:divi' => true, '@group:a b' => 'direct'])
);
}
public function testOldFlatOptionStaysValid(): void
{
$old = ['lit/content-get' => 'indirect', 'lit/menu-list' => 'direct'];
self::assertSame($old, ExposurePolicy::sanitize($old, ['core', 'wordpress']));
}
public function testFromOptionReadsGroupEntries(): void
{
Functions\when('get_option')->justReturn(['@group:divi' => 'disabled', 'lit/divi-update' => 'indirect']);
$policy = ExposurePolicy::fromOption()->withGroups(self::GROUPS);
self::assertSame('disabled', $policy->mode($this->ability('lit/divi-outline', true)));
self::assertSame('indirect', $policy->mode($this->ability('lit/divi-update', false)));
}
public function testGroupMapCanBeResolvedLazily(): void
{
$calls = 0;
$policy = (new ExposurePolicy(['@group:divi' => 'disabled']))->withGroups(
static function () use (&$calls): array {
$calls++;
return self::GROUPS;
}
);
self::assertSame(0, $calls, 'The map must not be built while booting (Divi is not loaded yet).');
self::assertSame('disabled', $policy->mode($this->ability('lit/divi-outline', true)));
self::assertSame('direct', $policy->mode($this->ability('lit/site-status', true)));
self::assertSame(1, $calls);
}
}