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
+191 -13
View File
@@ -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:<integration id>" => mode. */
public const GROUP_PREFIX = '@group:';
/** Option key prefix of a group's read-only switch: "@readonly:<integration id>" => 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<string, string> $overrides Ability name => mode. */
public function __construct(private array $overrides = [])
/** @var array<string, string> Ability name => mode. */
private array $tools = [];
/** @var array<string, string> Group id => mode. */
private array $groupModes = [];
/** @var array<string, true> Group ids switched to "nur lesen". */
private array $readonlyGroups = [];
/** @var array<string, string>|null Ability name => group id; null until resolved. */
private ?array $groups = [];
/** @var (callable(): array<string, string>)|null Lazy source of $groups. */
private $groupSource = null;
/**
* @param array<string, string|bool> $overrides Option content: ability name => mode, plus
* "@group:<id>" => mode and "@readonly:<id>" => 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<string, string>|(callable(): array<string, string>) $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:<id>" => mode and "@readonly:<id>" => true; drops everything else. With
* $groupIds, only those group ids are accepted.
*
* @return array<string, string>
* @param list<string>|null $groupIds Known group (integration) ids; null = any well-formed id.
* @return array<string, string|true>
*/
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) {
foreach ($input as $key => $value) {
if (! is_string($key)) {
continue;
}
if (str_starts_with($key, self::GROUP_PREFIX)) {
if (
is_string($name)
&& is_string($mode)
&& preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $name) === 1
&& self::isAllowed($name, $mode)
self::isGroupId(substr($key, strlen(self::GROUP_PREFIX)), $groupIds)
&& is_string($value)
&& in_array($value, self::MODES, true)
) {
$clean[$name] = $mode;
$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[$key] = $value;
}
}
ksort($clean);
return $clean;
}
/** @param list<string>|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);
}
}
+159 -24
View File
@@ -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<string, string|true>
*/
public function sanitize(mixed $input): array
{
return ExposurePolicy::sanitize($input, $this->registry->ids());
}
public function registerActions(): void
{
}
@@ -64,30 +80,121 @@ final class ToolsTab implements AdminTab
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
'wp-lit-mcp'
) . '</p>';
$policy = $this->policy->withGroups($this->registry->groupMap());
$this->renderCounter(count($policy->directNames($this->registry->abilities())));
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->grouped() as $group => $abilities) {
$this->renderGroup($policy, $group, $abilities);
}
submit_button();
echo '</form>';
}
private function renderCounter(int $direct): void
{
echo '<p><strong>' . esc_html(sprintf(
/* translators: %d: number of direct tools */
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
$direct
)) . '</strong></p>';
if ($direct > self::DIRECT_WARNING_THRESHOLD) {
echo '<p class="description"><strong>' . esc_html__('Achtung:', 'wp-lit-mcp') . '</strong> '
. 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')
. '</p>';
}
}
/** @param list<Ability> $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());
}
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),
'<details open class="lit-mcp-tool-group"><summary>%s</summary>',
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(
'<p><label>%s <select name="%s[%s]">',
esc_html__('Ganze Gruppe:', 'wp-lit-mcp'),
esc_attr(ExposurePolicy::OPTION),
esc_attr($name)
esc_attr(ExposurePolicy::GROUP_PREFIX . $group)
);
printf(
'<option value=""%s>%s</option>',
selected($isDefault, true, false),
esc_html(sprintf(
selected($groupMode === null, true, false),
esc_html__('Standard der Tools', 'wp-lit-mcp')
);
foreach (ExposurePolicy::MODES as $mode) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr($mode),
selected($groupMode === $mode, true, false),
esc_html($this->modeLabel($mode))
);
}
echo '</select></label> ';
printf(
'<label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label>',
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 ' <em>' . esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp') . '</em>';
}
echo '</p>';
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 ($abilities as $ability) {
$this->renderTool($policy, $ability, $groupMode !== null);
}
echo '</tbody></table></details>';
}
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(
'<tr><td><strong>%s</strong><br><code>%s</code></td><td><select name="%s[%s]"%s>',
esc_html($ability->label()),
esc_html($name),
esc_attr(ExposurePolicy::OPTION),
esc_attr($name),
$locked ? ' disabled' : ''
);
$inherited = $this->modeLabel($policy->inheritedMode($ability));
printf(
'<option value=""%s>%s</option>',
selected($own === null, true, false),
esc_html($groupOverride && ! $required
/* translators: %s: mode inherited from the group */
? sprintf(__('wie Gruppe (%s)', 'wp-lit-mcp'), $inherited)
/* translators: %s: default mode */
__('Standard (%s)', 'wp-lit-mcp'),
$this->modeLabel($this->policy->defaultMode($ability))
))
: sprintf(__('Standard (%s)', 'wp-lit-mcp'), $this->modeLabel($policy->defaultMode($ability))))
);
foreach (ExposurePolicy::MODES as $mode) {
if (! ExposurePolicy::isAllowed($name, $mode)) {
@@ -96,16 +203,44 @@ final class ToolsTab implements AdminTab
printf(
'<option value="%s"%s>%s</option>',
esc_attr($mode),
selected(! $isDefault && $current === $mode, true, false),
selected($own === $mode, true, false),
esc_html($this->modeLabel($mode))
);
}
echo '</select></td></tr>';
echo '</select>';
if ($locked && $own !== null) {
// A disabled select is not submitted – keep the tool's own setting for later.
printf(
'<input type="hidden" name="%s[%s]" value="%s">',
esc_attr(ExposurePolicy::OPTION),
esc_attr($name),
esc_attr($own)
);
}
echo '</tbody></table>';
submit_button();
echo '</form>';
$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 ' <em>' . esc_html($note) . '</em>';
}
echo '</td></tr>';
}
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
+53 -7
View File
@@ -11,8 +11,8 @@ final class IntegrationRegistry
/** @var list<Integration> */
private array $integrations;
/** @var list<Ability>|null */
private ?array $abilities = null;
/** @var list<array{Integration, list<Ability>}>|null Available integrations with their abilities. */
private ?array $loaded = null;
public function __construct(Integration ...$integrations)
{
@@ -31,15 +31,61 @@ final class IntegrationRegistry
/** @return list<Ability> */
public function abilities(): array
{
if ($this->abilities === null) {
$abilities = [];
foreach ($this->available() as $integration) {
array_push($abilities, ...$integration->abilities());
foreach ($this->loaded() as [, $own]) {
array_push($abilities, ...$own);
}
$this->abilities = $abilities;
return $abilities;
}
return $this->abilities;
/**
* Abilities of the available integrations, grouped by integration id (registry order).
*
* @return array<string, list<Ability>>
*/
public function grouped(): array
{
$groups = [];
foreach ($this->loaded() as [$integration, $own]) {
$groups[$integration->id()] = array_merge($groups[$integration->id()] ?? [], $own);
}
return $groups;
}
/** @return array<string, string> 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<string> 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<array{Integration, list<Ability>}> */
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;
}
}
+1 -1
View File
@@ -71,7 +71,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,
@@ -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();
}
}
+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);
}
}
+191 -25
View File
@@ -15,53 +15,219 @@ use Mockery;
final class ToolsTabTest extends TestCase
{
private function registry(): IntegrationRegistry
/** @param list<array{string, bool, bool}> $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<string, string|bool> $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('<select', $html), 1);
self::assertCount(3, $selects);
[$guide, $preview, $menu] = $selects;
self::assertStringContainsString('lit_mcp_exposure[lit/guide]', $guide);
return (string) ob_get_clean();
}
private function select(string $html, string $key): string
{
$start = strpos($html, '<select name="lit_mcp_exposure[' . $key . ']"');
self::assertNotFalse($start, 'No select for ' . $key);
return substr($html, $start, (int) strpos($html, '</select>', $start) - $start);
}
/** The table row of one tool (from its <tr> to </tr>). */
private function row(string $html, string $name): string
{
$at = strpos($html, '<code>' . $name . '</code>');
self::assertNotFalse($at, 'No row for ' . $name);
$start = (int) strrpos(substr($html, 0, $at), '<tr');
return substr($html, $start, (int) strpos($html, '</tr>', $at) - $start);
}
/** The <details> block of one group. */
private function group(string $html, string $label): string
{
$at = strpos($html, '<summary>' . $label);
self::assertNotFalse($at, 'No group ' . $label);
$start = (int) strrpos(substr($html, 0, $at), '<details');
return substr($html, $start, (int) strpos($html, '</details>', $at) - $start);
}
public function testRegistersTheExposureOptionWithAGroupAwareSanitizer(): void
{
$tab = new ToolsTab($this->registry(), new ExposurePolicy());
$callback = null;
Functions\expect('register_setting')->once()->with(
'lit_mcp_tools',
'lit_mcp_exposure',
Mockery::on(static function (array $args) use (&$callback): bool {
$callback = $args['sanitize_callback'];
return true;
})
);
$tab->registerSettings();
self::assertSame([$tab, 'sanitize'], $callback);
self::assertSame(
['@group:pods' => 'disabled', '@readonly:divi' => true, 'lit/menu-list' => 'disabled'],
$tab->sanitize([
'@readonly:divi' => '1',
'@group:pods' => 'disabled',
'@group:shop' => 'direct',
'lit/menu-list' => 'disabled',
'lit/guide' => 'disabled',
])
);
}
public function testGroupsToolsByAvailableIntegrationWithOnlyAllowedModes(): void
{
$html = $this->render(['lit/divi-update' => 'disabled']);
self::assertSame(3, substr_count($html, '<details open'));
self::assertStringContainsString('<summary>Grundlagen (2 Tools)', $html);
self::assertStringContainsString('<summary>Medien (2 Tools)', $html);
self::assertStringContainsString('<summary>Divi (2 Tools)', $html);
self::assertStringNotContainsString('pods', $html);
self::assertStringContainsString('lit/divi-outline', $this->group($html, 'Divi'));
self::assertStringNotContainsString('lit/guide', $this->group($html, 'Divi'));
$groupSelect = $this->select($html, '@group:divi');
self::assertStringContainsString('<option value="" selected="selected">Standard der Tools</option>', $groupSelect);
foreach (['direct', 'indirect', 'disabled'] as $mode) {
self::assertStringContainsString('value="' . $mode . '"', $groupSelect);
}
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1"', $html);
self::assertStringContainsString('nur lesen (schreibende Tools dieser Gruppe aus)', $html);
$guide = $this->select($html, 'lit/guide');
self::assertStringNotContainsString('value="disabled"', $guide);
self::assertStringNotContainsString('value="indirect"', $preview);
self::assertStringContainsString('value="disabled" selected="selected"', $menu);
self::assertStringContainsString('value="" selected="selected"', $guide);
self::assertStringNotContainsString('value="indirect"', $guide);
self::assertStringContainsString('value="" selected="selected">Standard (direkt)', $guide);
self::assertStringNotContainsString('value="indirect"', $this->select($html, 'lit/media-preview'));
self::assertStringContainsString('value="disabled" selected="selected"', $this->select($html, 'lit/divi-update'));
}
public function testToolsOfAGroupWithOverrideOfferWieGruppeAndTheGroupSaysGemischt(): void
{
$html = $this->render(['@group:divi' => 'indirect', 'lit/divi-update' => 'direct']);
self::assertStringContainsString('value="indirect" selected="selected"', $this->select($html, '@group:divi'));
self::assertStringContainsString(
'<option value="" selected="selected">wie Gruppe (indirekt)</option>',
$this->select($html, 'lit/divi-outline')
);
$update = $this->select($html, 'lit/divi-update');
self::assertStringContainsString('<option value="">wie Gruppe (indirekt)</option>', $update);
self::assertStringContainsString('value="direct" selected="selected"', $update);
self::assertStringContainsString('gemischt', $this->group($html, 'Divi'));
self::assertStringNotContainsString('gemischt', $this->group($html, 'Medien'));
self::assertStringContainsString('Standard (direkt)', $this->select($html, 'lit/site-status'));
}
public function testDirectOnlyToolsExplainThatIndirectDoesNotApply(): void
{
$html = $this->render(['@group:media' => 'indirect']);
self::assertStringContainsString('wie Gruppe (direkt)', $this->select($html, 'lit/media-preview'));
self::assertStringContainsString('„indirekt“ geht hier nicht', $this->row($html, 'lit/media-preview'));
self::assertStringNotContainsString('geht hier nicht', $this->row($html, 'lit/media-upload'));
}
public function testReadonlyDisablesWriteToolSelectsButKeepsTheirOverride(): void
{
$html = $this->render(['@readonly:divi' => true, 'lit/divi-update' => 'indirect']);
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1" checked="checked"', $html);
$update = $this->row($html, 'lit/divi-update');
self::assertStringContainsString('<select name="lit_mcp_exposure[lit/divi-update]" disabled', $update);
self::assertStringContainsString('durch ‚nur lesen‘ aus', $update);
self::assertStringContainsString(
'<input type="hidden" name="lit_mcp_exposure[lit/divi-update]" value="indirect">',
$update
);
$outline = $this->row($html, 'lit/divi-outline');
self::assertStringNotContainsString('disabled>', $outline);
self::assertStringNotContainsString('nur lesen', $outline);
}
public function testCountsDirectToolsUnderTheSavedSettings(): void
{
$html = $this->render(['@group:divi' => 'disabled']);
// guide, site-status, media-preview (divi disabled, media-upload indirect by default).
self::assertStringContainsString('Direkt sichtbar: 3 Tools', $html);
self::assertStringNotContainsString('Kontext', $html);
self::assertLessThan(strpos($html, '<details'), strpos($html, 'Direkt sichtbar'));
}
public function testWarnsWhenMoreThan25ToolsAreDirect(): void
{
$many = [];
for ($i = 1; $i <= 26; $i++) {
$many[] = ['lit/tool-' . $i, true, true];
}
$registry = new IntegrationRegistry($this->integration('wordpress', $many));
$warned = $this->render([], $registry);
$notWarned = $this->render(['lit/tool-1' => 'indirect'], $registry);
self::assertStringContainsString('Direkt sichtbar: 26 Tools', $warned);
self::assertStringContainsString('Kontext', $warned);
self::assertStringContainsString('<summary>WordPress (26 Tools)', $warned);
self::assertStringContainsString('Direkt sichtbar: 25 Tools', $notWarned);
self::assertStringNotContainsString('Kontext', $notWarned);
}
public function testUnknownGroupsUseTheirIdAndEverythingIsEscaped(): void
{
$registry = new IntegrationRegistry($this->integration('shop', [['lit/shop-<b>', true, true]]));
$html = $this->render([], $registry);
self::assertStringContainsString('<summary>shop (1 Tool)', $html);
self::assertStringNotContainsString('<b>', $html);
self::assertStringContainsString('lit/shop-&lt;b&gt;', $html);
}
}
@@ -98,4 +98,24 @@ final class InstructionsBuilderTest extends TestCase
self::assertStringContainsString('lit-menu-list', $text);
}
public function testGroupDisabledAndReadonlyToolsAreNotListedAsDirect(): void
{
$write = $this->ability('lit/divi-update', true);
$write->allows('annotations')->andReturn(['readonly' => false]);
$read = $this->ability('lit/divi-outline', true);
$read->allows('annotations')->andReturn(['readonly' => true]);
$divi = Mockery::mock(Integration::class);
$divi->allows('id')->andReturn('divi');
$divi->allows('isAvailable')->andReturn(true);
$divi->allows('abilities')->andReturn([$write, $read]);
$core = $this->coreWith($this->ability('lit/guide', true), $this->ability('lit/menu-list', true));
$registry = new IntegrationRegistry($core, $divi);
$policy = (new ExposurePolicy(['@group:core' => 'disabled', '@readonly:divi' => true]))
->withGroups($registry->groupMap());
$text = (new InstructionsBuilder($registry, null, $policy))->build();
self::assertStringContainsString('Direkt aufrufbare Tools: lit-guide, lit-divi-outline.', $text);
}
}
@@ -27,6 +27,28 @@ final class IntegrationRegistryTest extends TestCase
self::assertSame([$direct, $indirect], $registry->abilities());
}
public function testGroupsAbilitiesByIntegrationIdAndMapsNamesToGroups(): void
{
$guide = $this->ability('lit/guide', true);
$outline = $this->ability('lit/divi-outline', true);
$hidden = $this->ability('lit/pods-x', true);
$divi = Mockery::mock(Integration::class);
$divi->allows('id')->andReturn('divi');
$divi->allows('isAvailable')->andReturn(true);
$divi->expects('abilities')->once()->andReturn([$outline]);
$registry = new IntegrationRegistry(
$this->integration('core', true, [$guide]),
$divi,
$this->integration('pods', false, [$hidden])
);
self::assertSame([$guide, $outline], $registry->abilities());
self::assertSame(['core' => [$guide], 'divi' => [$outline]], $registry->grouped());
self::assertSame(['lit/guide' => 'core', 'lit/divi-outline' => 'divi'], $registry->groupMap());
self::assertSame(['core', 'divi', 'pods'], $registry->ids());
}
private function ability(string $name, bool $direct): Ability
{
$ability = Mockery::mock(Ability::class);
+19
View File
@@ -32,6 +32,7 @@ final class ServerRegistrarTest extends TestCase
$indirect = Mockery::mock(Ability::class);
$indirect->allows('name')->andReturn('lit/rare-tool');
$indirect->allows('isDirect')->andReturn(false);
$indirect->allows('annotations')->andReturn([]);
$core = Mockery::mock(Integration::class);
$core->allows('id')->andReturn('core');
@@ -89,6 +90,24 @@ final class ServerRegistrarTest extends TestCase
);
}
public function testGroupSettingsReachTheToolListAndTheInstructions(): void
{
$registry = $this->registry();
$policy = (new ExposurePolicy(['@group:core' => 'disabled', '@readonly:core' => true]))
->withGroups($registry->groupMap());
$instructions = new InstructionsBuilder($registry, null, $policy);
$args = (new ServerRegistrar($registry, $instructions, static fn () => true, null, null, $policy))
->serverArguments();
// lit/guide is REQUIRED and stays direct; lit/rare-tool is disabled by the group.
self::assertSame(
['lit/guide', 'mcp-adapter/discover-abilities', 'mcp-adapter/get-ability-info', 'mcp-adapter/execute-ability'],
$args['tools']
);
self::assertStringContainsString('Direkt aufrufbare Tools: lit-guide.', $args['description']);
}
public function testNoServerWhenOauthIsDisabled(): void
{
$registrar = new ServerRegistrar($this->registry(), $this->instructions(), static fn () => false);