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:
@@ -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) {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user