diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php
index 3f5443b..1db96b8 100644
--- a/src/Abilities/ExposurePolicy.php
+++ b/src/Abilities/ExposurePolicy.php
@@ -7,6 +7,12 @@ 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.
+ *
+ * Abilities are grouped by the integration they come from. Resolution: tool override ›
+ * group override › tool default. A group's "nur lesen" switch disables every ability of the
+ * group that is not annotated readonly, whatever the modes say. REQUIRED abilities always
+ * keep their default. Group entries live in the same option under keys that start with "@"
+ * (ability names always contain "/", so they cannot collide).
*/
final class ExposurePolicy
{
@@ -16,6 +22,12 @@ final class ExposurePolicy
public const DISABLED = 'disabled';
public const MODES = [self::DIRECT, self::INDIRECT, self::DISABLED];
+ /** Option key prefix of a group's mode: "@group:" => mode. */
+ public const GROUP_PREFIX = '@group:';
+
+ /** Option key prefix of a group's read-only switch: "@readonly:" => true. */
+ public const READONLY_PREFIX = '@readonly:';
+
/**
* Image/embedded-resource results only work for direct tools (ruling Q6 / task 6
* dispatch): lit/media-preview (ImageContent) and lit/media-download (embedded
@@ -26,9 +38,41 @@ final class ExposurePolicy
/** Entry point for every conversation – must always stay a direct tool. */
public const REQUIRED = ['lit/guide'];
- /** @param array $overrides Ability name => mode. */
- public function __construct(private array $overrides = [])
+ /** @var array Ability name => mode. */
+ private array $tools = [];
+
+ /** @var array Group id => mode. */
+ private array $groupModes = [];
+
+ /** @var array Group ids switched to "nur lesen". */
+ private array $readonlyGroups = [];
+
+ /** @var array|null Ability name => group id; null until resolved. */
+ private ?array $groups = [];
+
+ /** @var (callable(): array)|null Lazy source of $groups. */
+ private $groupSource = null;
+
+ /**
+ * @param array $overrides Option content: ability name => mode, plus
+ * "@group:" => mode and "@readonly:" => true.
+ */
+ public function __construct(array $overrides = [])
{
+ foreach ($overrides as $key => $value) {
+ $key = (string) $key;
+ if (str_starts_with($key, self::GROUP_PREFIX)) {
+ if (is_string($value) && in_array($value, self::MODES, true)) {
+ $this->groupModes[substr($key, strlen(self::GROUP_PREFIX))] = $value;
+ }
+ } elseif (str_starts_with($key, self::READONLY_PREFIX)) {
+ if ($value === true) {
+ $this->readonlyGroups[substr($key, strlen(self::READONLY_PREFIX))] = true;
+ }
+ } elseif (is_string($value)) {
+ $this->tools[$key] = $value;
+ }
+ }
}
public static function fromOption(): self
@@ -36,11 +80,98 @@ final class ExposurePolicy
return new self(self::sanitize(get_option(self::OPTION, [])));
}
+ /**
+ * Copy of this policy that knows which group (integration id) each ability belongs to.
+ * Without it, group entries have no effect. A callable is resolved on first use – boot runs
+ * on plugins_loaded, before the theme (Divi) is loaded, so the map must not be built there.
+ *
+ * @param array|(callable(): array) $groups Ability name => group id.
+ */
+ public function withGroups(array|callable $groups): self
+ {
+ $clone = clone $this;
+ if (is_array($groups)) {
+ $clone->groups = $groups;
+ $clone->groupSource = null;
+ } else {
+ $clone->groups = null;
+ $clone->groupSource = $groups;
+ }
+
+ return $clone;
+ }
+
public function mode(Ability $ability): string
{
- $mode = $this->overrides[$ability->name()] ?? null;
+ if (in_array($ability->name(), self::REQUIRED, true)) {
+ return $this->defaultMode($ability);
+ }
- return is_string($mode) && self::isAllowed($ability->name(), $mode) ? $mode : $this->defaultMode($ability);
+ if ($this->disabledByReadonly($ability)) {
+ return self::DISABLED;
+ }
+
+ return $this->toolOverride($ability->name()) ?? $this->inheritedMode($ability);
+ }
+
+ /** Mode the ability gets without an override of its own: group override › tool default. */
+ public function inheritedMode(Ability $ability): string
+ {
+ $name = $ability->name();
+ $groupMode = $this->groupModeOf($name);
+ if ($groupMode === null || in_array($name, self::REQUIRED, true)) {
+ return $this->defaultMode($ability);
+ }
+
+ return self::isAllowed($name, $groupMode) ? $groupMode : self::DIRECT;
+ }
+
+ /** True if the group's mode is not allowed for this ability and it falls back to "direct". */
+ public function groupModeAdjusted(Ability $ability): bool
+ {
+ $name = $ability->name();
+ $groupMode = $this->groupModeOf($name);
+
+ return $groupMode !== null
+ && ! in_array($name, self::REQUIRED, true)
+ && ! self::isAllowed($name, $groupMode);
+ }
+
+ /** True if the group's "nur lesen" switch disables this (writing) ability. */
+ public function disabledByReadonly(Ability $ability): bool
+ {
+ $group = $this->groupOf($ability->name());
+ if ($group === null || ! isset($this->readonlyGroups[$group])) {
+ return false;
+ }
+
+ if (in_array($ability->name(), self::REQUIRED, true)) {
+ return false;
+ }
+
+ return ($ability->annotations()['readonly'] ?? false) !== true;
+ }
+
+ public function hasToolOverride(string $name): bool
+ {
+ return $this->toolOverride($name) !== null;
+ }
+
+ public function toolOverride(string $name): ?string
+ {
+ $mode = $this->tools[$name] ?? null;
+
+ return $mode !== null && self::isAllowed($name, $mode) ? $mode : null;
+ }
+
+ public function groupMode(string $group): ?string
+ {
+ return $this->groupModes[$group] ?? null;
+ }
+
+ public function isReadonly(string $group): bool
+ {
+ return isset($this->readonlyGroups[$group]);
}
public function defaultMode(Ability $ability): string
@@ -94,29 +225,76 @@ final class ExposurePolicy
}
/**
- * Settings API sanitize callback for the option.
+ * Settings API sanitize callback for the option. Keeps valid ability entries (name => allowed
+ * mode), "@group:" => mode and "@readonly:" => true; drops everything else. With
+ * $groupIds, only those group ids are accepted.
*
- * @return array
+ * @param list|null $groupIds Known group (integration) ids; null = any well-formed id.
+ * @return array
*/
- public static function sanitize(mixed $input): array
+ public static function sanitize(mixed $input, ?array $groupIds = null): 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)
+ foreach ($input as $key => $value) {
+ if (! is_string($key)) {
+ continue;
+ }
+
+ if (str_starts_with($key, self::GROUP_PREFIX)) {
+ if (
+ self::isGroupId(substr($key, strlen(self::GROUP_PREFIX)), $groupIds)
+ && is_string($value)
+ && in_array($value, self::MODES, true)
+ ) {
+ $clean[$key] = $value;
+ }
+ } elseif (str_starts_with($key, self::READONLY_PREFIX)) {
+ if (
+ self::isGroupId(substr($key, strlen(self::READONLY_PREFIX)), $groupIds)
+ && ($value === true || $value === '1' || $value === 1)
+ ) {
+ $clean[$key] = true;
+ }
+ } elseif (
+ is_string($value)
+ && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $key) === 1
+ && self::isAllowed($key, $value)
) {
- $clean[$name] = $mode;
+ $clean[$key] = $value;
}
}
ksort($clean);
return $clean;
}
+
+ /** @param list|null $groupIds */
+ private static function isGroupId(string $id, ?array $groupIds): bool
+ {
+ if (preg_match('#^[a-z0-9-]+$#', $id) !== 1) {
+ return false;
+ }
+
+ return $groupIds === null || in_array($id, $groupIds, true);
+ }
+
+ private function groupOf(string $name): ?string
+ {
+ if ($this->groups === null) {
+ $this->groups = $this->groupSource !== null ? ($this->groupSource)() : [];
+ }
+
+ return $this->groups[$name] ?? null;
+ }
+
+ private function groupModeOf(string $name): ?string
+ {
+ $group = $this->groupOf($name);
+
+ return $group === null ? null : ($this->groupModes[$group] ?? null);
+ }
}
diff --git a/src/Admin/ToolsTab.php b/src/Admin/ToolsTab.php
index 00b7aa4..c18b264 100644
--- a/src/Admin/ToolsTab.php
+++ b/src/Admin/ToolsTab.php
@@ -4,18 +4,24 @@ declare(strict_types=1);
namespace LIT\MCP\Admin;
+use LIT\MCP\Abilities\Ability;
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.
+ * Tab "Tools": direct/indirect/disabled exposure per ability (spec §8.4), grouped by the
+ * integration the ability comes from. Each group has its own mode (inherited by its tools)
+ * and a "nur lesen" switch. Only the modes ExposurePolicy::isAllowed() allows for a given
+ * ability are offered; "Standard"/"wie Gruppe" saves nothing, so a later version's changed
+ * default takes effect automatically.
*/
final class ToolsTab implements AdminTab
{
public const GROUP = 'lit_mcp_tools';
+ /** More direct tools than this cost noticeable context in every claude.ai conversation. */
+ public const DIRECT_WARNING_THRESHOLD = 25;
+
public function __construct(private IntegrationRegistry $registry, private ExposurePolicy $policy)
{
}
@@ -34,11 +40,21 @@ final class ToolsTab implements AdminTab
{
register_setting(self::GROUP, ExposurePolicy::OPTION, [
'type' => 'array',
- 'sanitize_callback' => [ExposurePolicy::class, 'sanitize'],
+ 'sanitize_callback' => [$this, 'sanitize'],
'default' => [],
]);
}
+ /**
+ * Sanitize callback: ExposurePolicy::sanitize() restricted to the known group ids.
+ *
+ * @return array
+ */
+ public function sanitize(mixed $input): array
+ {
+ return ExposurePolicy::sanitize($input, $this->registry->ids());
+ }
+
public function registerActions(): void
{
}
@@ -64,50 +80,169 @@ final class ToolsTab implements AdminTab
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
'wp-lit-mcp'
) . '
';
+ $policy = $this->policy->withGroups($this->registry->groupMap());
+ $this->renderCounter(count($policy->directNames($this->registry->abilities())));
+
echo '';
}
+ private function renderCounter(int $direct): void
+ {
+ echo '' . esc_html(sprintf(
+ /* translators: %d: number of direct tools */
+ _n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
+ $direct
+ )) . '
';
+
+ if ($direct > self::DIRECT_WARNING_THRESHOLD) {
+ echo '' . esc_html__('Achtung:', 'wp-lit-mcp') . ' '
+ . esc_html__(
+ 'Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.',
+ 'wp-lit-mcp'
+ ) . ' '
+ . esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp')
+ . '
';
+ }
+ }
+
+ /** @param list $abilities */
+ private function renderGroup(ExposurePolicy $policy, string $group, array $abilities): void
+ {
+ $count = count($abilities);
+ $mixed = false;
+ foreach ($abilities as $ability) {
+ $mixed = $mixed || $policy->hasToolOverride($ability->name());
+ }
+
+ printf(
+ '%s
',
+ esc_html(sprintf(
+ /* translators: 1: group label, 2: number of tools */
+ _n('%1$s (%2$d Tool)', '%1$s (%2$d Tools)', $count, 'wp-lit-mcp'),
+ $this->groupLabel($group),
+ $count
+ ))
+ );
+
+ $groupMode = $policy->groupMode($group);
+ printf(
+ ' ';
+ printf(
+ '',
+ esc_attr(ExposurePolicy::OPTION),
+ esc_attr(ExposurePolicy::READONLY_PREFIX . $group),
+ checked($policy->isReadonly($group), true, false),
+ esc_html__('nur lesen (schreibende Tools dieser Gruppe aus)', 'wp-lit-mcp')
+ );
+ if ($mixed) {
+ echo ' ' . esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp') . '';
+ }
+ echo '
';
+
+ echo '| ' . esc_html__('Tool', 'wp-lit-mcp') . ' | '
+ . esc_html__('Einstellung', 'wp-lit-mcp') . ' |
';
+ foreach ($abilities as $ability) {
+ $this->renderTool($policy, $ability, $groupMode !== null);
+ }
+ echo '
';
+ }
+
+ private function renderTool(ExposurePolicy $policy, Ability $ability, bool $groupOverride): void
+ {
+ $name = $ability->name();
+ $own = $policy->toolOverride($name);
+ $locked = $policy->disabledByReadonly($ability);
+ $required = in_array($name, ExposurePolicy::REQUIRED, true);
+
+ printf(
+ '%s
%s | ';
+
+ if ($locked && $own !== null) {
+ // A disabled select is not submitted – keep the tool's own setting for later.
+ printf(
+ '',
+ esc_attr(ExposurePolicy::OPTION),
+ esc_attr($name),
+ esc_attr($own)
+ );
+ }
+
+ $note = match (true) {
+ $locked => __('durch ‚nur lesen‘ aus', 'wp-lit-mcp'),
+ $own === null && $policy->groupModeAdjusted($ability)
+ => __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'),
+ default => '',
+ };
+ if ($note !== '') {
+ echo ' ' . esc_html($note) . '';
+ }
+ echo ' |
';
+ }
+
+ private function groupLabel(string $group): string
+ {
+ return match ($group) {
+ 'core' => __('Grundlagen', 'wp-lit-mcp'),
+ 'wordpress' => __('WordPress', 'wp-lit-mcp'),
+ 'media' => __('Medien', 'wp-lit-mcp'),
+ 'pods' => __('Pods', 'wp-lit-mcp'),
+ 'divi' => __('Divi', 'wp-lit-mcp'),
+ default => $group,
+ };
+ }
+
private function modeLabel(string $mode): string
{
return match ($mode) {
diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php
index d9f7341..68345e7 100644
--- a/src/Integrations/IntegrationRegistry.php
+++ b/src/Integrations/IntegrationRegistry.php
@@ -11,8 +11,8 @@ final class IntegrationRegistry
/** @var list */
private array $integrations;
- /** @var list|null */
- private ?array $abilities = null;
+ /** @var list}>|null Available integrations with their abilities. */
+ private ?array $loaded = null;
public function __construct(Integration ...$integrations)
{
@@ -31,15 +31,61 @@ final class IntegrationRegistry
/** @return list */
public function abilities(): array
{
- if ($this->abilities === null) {
- $abilities = [];
- foreach ($this->available() as $integration) {
- array_push($abilities, ...$integration->abilities());
- }
-
- $this->abilities = $abilities;
+ $abilities = [];
+ foreach ($this->loaded() as [, $own]) {
+ array_push($abilities, ...$own);
}
- return $this->abilities;
+ return $abilities;
+ }
+
+ /**
+ * Abilities of the available integrations, grouped by integration id (registry order).
+ *
+ * @return array>
+ */
+ public function grouped(): array
+ {
+ $groups = [];
+ foreach ($this->loaded() as [$integration, $own]) {
+ $groups[$integration->id()] = array_merge($groups[$integration->id()] ?? [], $own);
+ }
+
+ return $groups;
+ }
+
+ /** @return array Ability name => integration id (the ability's group). */
+ public function groupMap(): array
+ {
+ $map = [];
+ foreach ($this->grouped() as $id => $abilities) {
+ foreach ($abilities as $ability) {
+ $map[$ability->name()] = $id;
+ }
+ }
+
+ return $map;
+ }
+
+ /** @return list Ids of all known integrations, available or not. */
+ public function ids(): array
+ {
+ return array_values(array_unique(array_map(
+ static fn (Integration $integration): string => $integration->id(),
+ $this->integrations
+ )));
+ }
+
+ /** @return list}> */
+ private function loaded(): array
+ {
+ if ($this->loaded === null) {
+ $this->loaded = array_map(
+ static fn (Integration $integration): array => [$integration, $integration->abilities()],
+ $this->available()
+ );
+ }
+
+ return $this->loaded;
}
}
diff --git a/src/Plugin.php b/src/Plugin.php
index c1fad1e..162fd13 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -73,7 +73,7 @@ final class Plugin
new DiviIntegration($schema, $changeLog)
);
- $policy = ExposurePolicy::fromOption();
+ $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
(new AbilityRegistrar($registry, $policy))->register();
(new ServerRegistrar(
$registry,
diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php
index 6b96417..4837b58 100644
--- a/tests/Unit/Abilities/AbilityRegistrarTest.php
+++ b/tests/Unit/Abilities/AbilityRegistrarTest.php
@@ -217,4 +217,18 @@ final class AbilityRegistrarTest extends TestCase
(new AbilityRegistrar(new IntegrationRegistry($integration), new ExposurePolicy(['lit/test' => 'disabled'])))
->registerAbilities();
}
+
+ public function testGroupDisabledAbilitiesAreNotRegistered(): void
+ {
+ $ability = $this->ability();
+ $integration = Mockery::mock(Integration::class);
+ $integration->allows('id')->andReturn('divi');
+ $integration->allows('isAvailable')->andReturn(true);
+ $integration->allows('abilities')->andReturn([$ability]);
+ $registry = new IntegrationRegistry($integration);
+ Functions\expect('wp_register_ability')->never();
+
+ (new AbilityRegistrar($registry, (new ExposurePolicy(['@group:divi' => 'disabled']))->withGroups($registry->groupMap())))
+ ->registerAbilities();
+ }
}
diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php
index e889cdb..d68f258 100644
--- a/tests/Unit/Abilities/ExposurePolicyTest.php
+++ b/tests/Unit/Abilities/ExposurePolicyTest.php
@@ -12,11 +12,15 @@ use Mockery;
final class ExposurePolicyTest extends TestCase
{
- private function ability(string $name, bool $direct): Ability
+ /** @param array|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:' => '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);
+ }
}
diff --git a/tests/Unit/Admin/ToolsTabTest.php b/tests/Unit/Admin/ToolsTabTest.php
index 9ec0249..09f2cfd 100644
--- a/tests/Unit/Admin/ToolsTabTest.php
+++ b/tests/Unit/Admin/ToolsTabTest.php
@@ -15,53 +15,219 @@ use Mockery;
final class ToolsTabTest extends TestCase
{
- private function registry(): IntegrationRegistry
+ /** @param list $abilities name, isDirect, readonly */
+ private function integration(string $id, array $abilities, bool $available = true): Integration
{
- $abilities = [];
- foreach ([['lit/guide', true], ['lit/media-preview', true], ['lit/menu-list', false]] as [$name, $direct]) {
+ $mocks = [];
+ foreach ($abilities as [$name, $direct, $readonly]) {
$ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn($name);
$ability->allows('label')->andReturn('Label ' . $name);
$ability->allows('isDirect')->andReturn($direct);
- $abilities[] = $ability;
+ $ability->allows('annotations')->andReturn(['readonly' => $readonly]);
+ $mocks[] = $ability;
}
$integration = Mockery::mock(Integration::class);
- $integration->allows('isAvailable')->andReturn(true);
- $integration->allows('abilities')->andReturn($abilities);
+ $integration->allows('id')->andReturn($id);
+ $integration->allows('isAvailable')->andReturn($available);
+ $integration->allows('abilities')->andReturn($mocks);
- return new IntegrationRegistry($integration);
+ return $integration;
}
- public function testRegistersTheExposureOptionWithThePolicySanitizer(): void
+ private function registry(): IntegrationRegistry
{
- 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'])
+ return new IntegrationRegistry(
+ $this->integration('core', [['lit/guide', true, true], ['lit/site-status', true, true]]),
+ $this->integration('media', [['lit/media-preview', true, true], ['lit/media-upload', false, false]]),
+ $this->integration('divi', [['lit/divi-outline', true, true], ['lit/divi-update', true, false]]),
+ $this->integration('pods', [['lit/pods-x', true, true]], false)
);
-
- (new ToolsTab($this->registry(), new ExposurePolicy()))->registerSettings();
}
- public function testRendersOneSelectPerAbilityWithOnlyAllowedModes(): void
+ /** @param array $option */
+ private function render(array $option, ?IntegrationRegistry $registry = null): string
{
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"' : '');
+ Functions\when('selected')->alias(
+ static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' selected="selected"' : ''
+ );
+ Functions\when('checked')->alias(
+ static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' checked="checked"' : ''
+ );
ob_start();
- (new ToolsTab($this->registry(), new ExposurePolicy(['lit/menu-list' => 'disabled'])))->render();
- $html = (string) ob_get_clean();
+ (new ToolsTab($registry ?? $this->registry(), new ExposurePolicy($option)))->render();
- $selects = array_slice(explode('