refactor: AbilityRegistrar nimmt AbilityGuard-Liste und ToolRefusal statt DiviToolGate/DiviLocked; DiviToolGate implementiert AbilityGuard, DiviLocked ToolRefusal; Test mit Fake-Guard und Fake-Refusal ohne Divi

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 21:47:26 +02:00
co-authored by Claude Sonnet 5.5
parent a7a9c09de8
commit abeb4b0eed
8 changed files with 257 additions and 26 deletions
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
use WP_Error;
defined('ABSPATH') || exit;
/**
* A cross-cutting rule the AbilityRegistrar applies to every ability it registers: it may refuse a call before the
* ability runs, add notes to the result and extend the output schema accordingly (e.g. the Divi release lock).
* The registrar knows only this interface; which guards exist is decided in Plugin (composition root).
*/
interface AbilityGuard
{
/**
* The refusal for this call before execute() runs, or null to let it through.
*
* @param array<string, mixed> $input
*/
public function refusal(Ability $ability, array $input): ?WP_Error;
/** The result of a finished call, possibly with added warnings. */
public function annotate(Ability $ability, mixed $result): mixed;
/**
* The output schema, possibly extended (e.g. by a warnings property).
*
* @param array<string, mixed>|null $schema
* @return array<string, mixed>|null
*/
public function outputSchema(Ability $ability, ?array $schema): ?array;
}
+41 -16
View File
@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace LIT\MCP\Abilities;
use LIT\MCP\Access;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Log;
use LIT\MCP\Server\RequestContext;
@@ -17,16 +15,20 @@ defined('ABSPATH') || exit;
/**
* Registers all abilities of available integrations with the Abilities API and wraps them
* with the role gate, the Divi release lock (DiviToolGate) and neutral error handling.
* with the role gate, the AbilityGuards it is given (e.g. the Divi release lock) and neutral error handling.
* It knows no integration: guards and ToolRefusal exceptions are the only seams.
*/
final class AbilityRegistrar
{
public const CATEGORY = 'lit-mcp';
/**
* @param list<AbilityGuard> $guards Applied in order: the first refusal wins, annotate() and outputSchema() chain.
*/
public function __construct(
private IntegrationRegistry $registry,
private ?ExposurePolicy $policy = null,
private ?DiviToolGate $divi = null
private array $guards = []
) {
}
@@ -55,7 +57,7 @@ final class AbilityRegistrar
/** @return array<string, mixed> */
public function argsFor(Ability $ability): array
{
$divi = $this->divi ?? new DiviToolGate($this->registry->groupMap(...));
$guards = $this->guards;
$args = [
'label' => $ability->label(),
'description' => $ability->description(),
@@ -77,14 +79,22 @@ final class AbilityRegistrar
return Errors::internal();
}
},
'execute_callback' => static function (mixed $input = null) use ($ability, $divi): mixed {
$refusal = $divi->refusal($ability);
if ($refusal !== null) {
return $refusal;
'execute_callback' => static function (mixed $input = null) use ($ability, $guards): mixed {
$input = is_array($input) ? $input : [];
foreach ($guards as $guard) {
$refusal = $guard->refusal($ability, $input);
if ($refusal !== null) {
return $refusal;
}
}
try {
return $divi->annotate($ability, $ability->execute(is_array($input) ? $input : []));
$result = $ability->execute($input);
foreach ($guards as $guard) {
$result = $guard->annotate($ability, $result);
}
return $result;
} catch (Throwable $e) {
Log::failure($ability->name(), $e);
@@ -104,7 +114,10 @@ final class AbilityRegistrar
],
];
$output = $divi->outputSchema($ability, $ability->outputSchema());
$output = $ability->outputSchema();
foreach ($guards as $guard) {
$output = $guard->outputSchema($ability, $output);
}
if ($output !== null) {
$args['output_schema'] = $output;
}
@@ -113,19 +126,31 @@ final class AbilityRegistrar
}
/**
* A partial write stays partial_write; otherwise a Divi release lock anywhere in the cause chain is its refusal
* (spec §5.5); a rolled-back write is write_failed; anything else internal_error.
* A partial write stays partial_write; otherwise the first ToolRefusal anywhere in the cause chain is the answer
* (e.g. the Divi release lock, spec §5.5); a rolled-back write is write_failed; anything else internal_error.
*/
private static function errorFor(Throwable $e): WP_Error
{
if ($e instanceof WriteFailed && ! $e->rolledBack) {
return $e->toError();
}
$locked = DiviLocked::in($e);
if ($locked !== null) {
return $locked->toError();
$refusal = self::refusalIn($e);
if ($refusal !== null) {
return $refusal->toError();
}
return $e instanceof WriteFailed ? $e->toError() : Errors::internal();
}
/** The first ToolRefusal in $e's chain of previous exceptions (callers wrap failures in WriteFailed). */
private static function refusalIn(Throwable $e): ?ToolRefusal
{
for ($cause = $e; $cause !== null; $cause = $cause->getPrevious()) {
if ($cause instanceof ToolRefusal) {
return $cause;
}
}
return null;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Abilities;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Implemented by exceptions that stand for a deliberate refusal instead of a failure (e.g. DiviLocked): thrown
* anywhere below an ability's execute(), also wrapped as the cause of a WriteFailed, the AbilityRegistrar answers
* with toError() instead of internal_error.
*/
interface ToolRefusal
{
/** The error the client gets for this refusal. */
public function toError(): WP_Error;
}
+2 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Abilities\ToolRefusal;
use RuntimeException;
use Throwable;
use WP_Error;
@@ -11,7 +12,7 @@ use WP_Error;
defined('ABSPATH') || exit;
/** A write refused because the installed Divi is not released (spec §5.5); AbilityRegistrar returns toError(). */
final class DiviLocked extends RuntimeException
final class DiviLocked extends RuntimeException implements ToolRefusal
{
public function __construct(public readonly ReleaseStatus $status)
{
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityGuard;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
@@ -17,9 +18,9 @@ defined('ABSPATH') || exit;
* The central release lock of the Divi tools (spec §5.5): a writing tool of the groups Divi, Divi-Bibliothek or Divi
* Theme Builder is refused before it runs while Divi is not released (logged with result "refused"); a reading tool
* runs and gets the notice in warnings. Other tools that reach Divi (lit/content-create with divi) meet the adapter
* lock (ReleaseGate::assertWritable()).
* lock (ReleaseGate::assertWritable()). The AbilityRegistrar receives it as an AbilityGuard from Plugin.
*/
final class DiviToolGate
final class DiviToolGate implements AbilityGuard
{
public const GROUPS = ['divi', LibraryAccess::GROUP, ThemeBuilderAccess::GROUP];
@@ -39,7 +40,7 @@ final class DiviToolGate
return in_array(($this->groups)()[$ability->name()] ?? '', self::GROUPS, true);
}
public function refusal(Ability $ability): ?WP_Error
public function refusal(Ability $ability, array $input): ?WP_Error
{
if (! $this->covers($ability) || self::readsOnly($ability)) {
return null;
+5 -1
View File
@@ -144,7 +144,11 @@ final class Plugin
DiviCheckCommand::register($diviCheck);
$policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...));
(new AbilityRegistrar($registry, $policy, new DiviToolGate($registry->groupMap(...), $changeLog)))->register();
(new AbilityRegistrar(
$registry,
$policy,
[new DiviToolGate($registry->groupMap(...), $changeLog)]
))->register();
(new ServerRegistrar(
$registry,
new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames(), $policy),