Files
WP-LIT-MCP/src/Abilities/ExposurePolicy.php
T
janniclas.loosenandClaude Opus 5.5 dca1bd67f8 refactor: integration labels, AdminPostAction, ExposurePolicy key parsing (M9)
Integrations name their own admin group (Integration::label, registry
labels()); ToolsTab no longer hard-codes ids and explains why it re-applies
withGroups. AccessTab and LogTab share AdminPostAction (guard, redirect,
terminate seam). ExposurePolicy parses option keys in one place, has one
isRequired() check and a clear $groups docblock (null vs []).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 18:31:15 +02:00

329 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
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
{
public const OPTION = 'lit_mcp_exposure';
public const DIRECT = 'direct';
public const INDIRECT = 'indirect';
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
* resource) can be direct or disabled, but never indirect.
*/
public const DIRECT_ONLY = ['lit/media-preview', 'lit/media-download'];
/** Entry point for every conversation – must always stay a direct tool. */
public const REQUIRED = ['lit/guide'];
/** @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 = [];
/**
* Ability name => group id. [] = no groups known (withGroups() not called: group entries have
* no effect); null = a lazy $groupSource is set and not resolved yet (groupOf() resolves it).
*
* @var array<string, string>|null
*/
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) {
['kind' => $kind, 'id' => $id] = self::parseKey((string) $key);
if ($kind === 'group') {
if (is_string($value) && in_array($value, self::MODES, true)) {
$this->groupModes[$id] = $value;
}
} elseif ($kind === 'readonly') {
if ($value === true) {
$this->readonlyGroups[$id] = true;
}
} elseif (is_string($value)) {
$this->tools[$id] = $value;
}
}
}
public static function fromOption(): self
{
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
{
if (self::isRequired($ability->name())) {
return $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 || self::isRequired($name)) {
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
&& ! self::isRequired($name)
&& ! 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 (self::isRequired($ability->name())) {
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
{
return $ability->isDirect() ? self::DIRECT : self::INDIRECT;
}
/**
* @param list<Ability> $abilities
* @return list<Ability>
*/
public function enabled(array $abilities): array
{
return array_values(array_filter(
$abilities,
fn (Ability $ability): bool => $this->mode($ability) !== self::DISABLED
));
}
/**
* @param list<Ability> $abilities
* @return list<string>
*/
public function directNames(array $abilities): array
{
$names = [];
foreach ($abilities as $ability) {
if ($this->mode($ability) === self::DIRECT) {
$names[] = $ability->name();
}
}
return $names;
}
public static function isAllowed(string $name, string $mode): bool
{
if (! in_array($mode, self::MODES, true)) {
return false;
}
if (self::isRequired($name)) {
return $mode === self::DIRECT;
}
if (in_array($name, self::DIRECT_ONLY, true)) {
return $mode !== self::INDIRECT;
}
return true;
}
/**
* 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.
*
* @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 $groupIds = null): array
{
if (! is_array($input)) {
return [];
}
$clean = [];
foreach ($input as $key => $value) {
if (! is_string($key)) {
continue;
}
['kind' => $kind, 'id' => $id] = self::parseKey($key);
if ($kind === 'group') {
if (
self::isGroupId($id, $groupIds)
&& is_string($value)
&& in_array($value, self::MODES, true)
) {
$clean[$key] = $value;
}
} elseif ($kind === 'readonly') {
if (
self::isGroupId($id, $groupIds)
&& ($value === true || $value === '1' || $value === 1)
) {
$clean[$key] = true;
}
} elseif (
is_string($value)
&& preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#D', $key) === 1
&& self::isAllowed($key, $value)
) {
$clean[$key] = $value;
}
}
ksort($clean);
return $clean;
}
/**
* What an option key names: "@group:<id>" a group's mode, "@readonly:<id>" a group's
* read-only switch, anything else an ability (id = the whole key).
*
* @return array{kind: 'group'|'readonly'|'tool', id: string}
*/
private static function parseKey(string $key): array
{
foreach (['group' => self::GROUP_PREFIX, 'readonly' => self::READONLY_PREFIX] as $kind => $prefix) {
if (str_starts_with($key, $prefix)) {
return ['kind' => $kind, 'id' => substr($key, strlen($prefix))];
}
}
return ['kind' => 'tool', 'id' => $key];
}
private static function isRequired(string $name): bool
{
return in_array($name, self::REQUIRED, true);
}
/** @param list<string>|null $groupIds */
private static function isGroupId(string $id, ?array $groupIds): bool
{
if (preg_match('#^[a-z0-9-]+$#D', $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);
}
}