feat: central Divi release lock for the Divi tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 01:54:36 +02:00
co-authored by Claude Opus 5.5
parent cf3c5dfe76
commit adfe0adfc2
8 changed files with 334 additions and 18 deletions
+72 -2
View File
@@ -9,6 +9,11 @@ use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityRegistrar;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\DiviToolGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Server\RequestContext;
@@ -43,7 +48,18 @@ final class AbilityRegistrarTest extends TestCase
return new AbilityRegistrar(new IntegrationRegistry($integration));
}
private function ability(): Mockery\MockInterface
private function diviRegistrar(Ability $ability): AbilityRegistrar
{
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn('divi');
$integration->allows('isAvailable')->andReturn(true);
$integration->allows('abilities')->andReturn([$ability]);
$registry = new IntegrationRegistry($integration);
return new AbilityRegistrar($registry, null, new DiviToolGate($registry->groupMap(...)));
}
private function ability(bool $readonly = true): Mockery\MockInterface
{
$ability = Mockery::mock(Ability::class);
$ability->allows('name')->andReturn('lit/test');
@@ -51,7 +67,7 @@ final class AbilityRegistrarTest extends TestCase
$ability->allows('description')->andReturn('Test ability');
$ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]);
$ability->allows('outputSchema')->andReturn(['type' => 'object']);
$ability->allows('annotations')->andReturn(['readonly' => true]);
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
$ability->allows('isDirect')->andReturn(true);
return $ability;
@@ -202,6 +218,60 @@ final class AbilityRegistrarTest extends TestCase
unlink($log);
}
public function testUntestedDiviRefusesAWritingDiviToolBeforeItRuns(): void
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
$ability = $this->ability(false);
$ability->expects('execute')->never();
$args = $this->diviRegistrar($ability)->argsFor($ability);
self::assertToolError(($args['execute_callback'])([]), 'divi_untested');
}
public function testALockThrownInsideAWriteBecomesTheLockError(): void
{
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['x']));
$ability = $this->ability();
$ability->allows('execute')->andThrow(WriteFailed::rolledBack('Nicht gespeichert.', $locked));
self::assertToolError($this->executeLogged($ability), 'divi_incompatible');
}
public function testAPartialWriteStaysPartialEvenWithALockInside(): void
{
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
$ability = $this->ability();
$ability->allows('execute')->andThrow(WriteFailed::partial('Teilweise gespeichert.', $locked));
self::assertToolError($this->executeLogged($ability), 'partial_write');
}
/**
* Runs the execute callback with error_log() redirected (a PHP internal, not stubbable – like the tests above).
*/
private function executeLogged(Ability $ability): mixed
{
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
return ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]);
} finally {
ini_set('error_log', (string) $previous);
unlink($log);
}
}
public function testDiviReadToolsGetWarningsInTheirSchema(): void
{
$ability = $this->ability();
$args = $this->diviRegistrar($ability)->argsFor($ability);
self::assertArrayHasKey('warnings', $args['output_schema']['properties']);
}
public function testRegisterAbilitiesCallsWordPressForEachAbility(): void
{
$ability = $this->ability();