From 3b7fabf17a8f7a0c3540de21493cd61ea042b13d Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 15:27:08 +0200 Subject: [PATCH] feat: ability and integration infrastructure Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Abilities/Ability.php | 38 ++++++ src/Abilities/AbilityRegistrar.php | 83 +++++++++++++ src/Abilities/Errors.php | 39 ++++++ src/Integrations/Integration.php | 20 +++ src/Integrations/IntegrationRegistry.php | 51 ++++++++ tests/Unit/Abilities/AbilityRegistrarTest.php | 116 ++++++++++++++++++ .../Integrations/IntegrationRegistryTest.php | 50 ++++++++ 7 files changed, 397 insertions(+) create mode 100644 src/Abilities/Ability.php create mode 100644 src/Abilities/AbilityRegistrar.php create mode 100644 src/Abilities/Errors.php create mode 100644 src/Integrations/Integration.php create mode 100644 src/Integrations/IntegrationRegistry.php create mode 100644 tests/Unit/Abilities/AbilityRegistrarTest.php create mode 100644 tests/Unit/Integrations/IntegrationRegistryTest.php diff --git a/src/Abilities/Ability.php b/src/Abilities/Ability.php new file mode 100644 index 0000000..095f73d --- /dev/null +++ b/src/Abilities/Ability.php @@ -0,0 +1,38 @@ + JSON Schema of the input. */ + public function inputSchema(): array; + + /** @return array|null JSON Schema of the output, null for image results. */ + public function outputSchema(): ?array; + + /** @return array{readonly?: bool, destructive?: bool, idempotent?: bool} */ + public function annotations(): array; + + /** Whether the ability is listed as its own MCP tool (otherwise only via meta-tools). */ + public function isDirect(): bool; + + /** @param array $input */ + public function permission(array $input): bool|WP_Error; + + /** @param array $input */ + public function execute(array $input): mixed; +} diff --git a/src/Abilities/AbilityRegistrar.php b/src/Abilities/AbilityRegistrar.php new file mode 100644 index 0000000..031074e --- /dev/null +++ b/src/Abilities/AbilityRegistrar.php @@ -0,0 +1,83 @@ + 'LIT MCP', + 'description' => 'Website-Pflege für WordPress, Divi und Pods über MCP.', + ]); + } + + public function registerAbilities(): void + { + foreach ($this->registry->abilities() as $ability) { + wp_register_ability($ability->name(), $this->argsFor($ability)); + } + } + + /** @return array */ + public function argsFor(Ability $ability): array + { + $args = [ + 'label' => $ability->label(), + 'description' => $ability->description(), + 'category' => self::CATEGORY, + 'input_schema' => $ability->inputSchema(), + 'permission_callback' => static function (mixed $input = null) use ($ability): bool|WP_Error { + if (! Access::currentUserMayUse()) { + return Errors::forbidden('Dein Benutzerkonto ist für LIT MCP nicht freigegeben.'); + } + + return $ability->permission(is_array($input) ? $input : []); + }, + 'execute_callback' => static function (mixed $input = null) use ($ability): mixed { + try { + return $ability->execute(is_array($input) ? $input : []); + } catch (Throwable $e) { + $message = sprintf('[wp-lit-mcp] %s failed: %s', $ability->name(), $e); + error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return Errors::internal(); + } + }, + 'meta' => [ + 'annotations' => $ability->annotations(), + 'show_in_rest' => false, + 'mcp' => ['public' => true, 'type' => 'tool'], + ], + ]; + + $output = $ability->outputSchema(); + if ($output !== null) { + $args['output_schema'] = $output; + } + + return $args; + } +} diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php new file mode 100644 index 0000000..3d59620 --- /dev/null +++ b/src/Abilities/Errors.php @@ -0,0 +1,39 @@ + 403]); + } + + /** @param array $fields */ + public static function invalidInput(string $message, array $fields = []): WP_Error + { + return new WP_Error('lit_validation_failed', $message, ['status' => 400, 'fields' => $fields]); + } + + public static function notFound(string $message): WP_Error + { + return new WP_Error('lit_not_found', $message, ['status' => 404]); + } + + public static function internal(): WP_Error + { + return new WP_Error( + 'lit_internal_error', + 'Interner Fehler bei der Ausführung. Details stehen im PHP-Error-Log der Website.', + ['status' => 500] + ); + } +} diff --git a/src/Integrations/Integration.php b/src/Integrations/Integration.php new file mode 100644 index 0000000..e19a45c --- /dev/null +++ b/src/Integrations/Integration.php @@ -0,0 +1,20 @@ + */ + public function abilities(): array; +} diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php new file mode 100644 index 0000000..4b75be3 --- /dev/null +++ b/src/Integrations/IntegrationRegistry.php @@ -0,0 +1,51 @@ + */ + private array $integrations; + + public function __construct(Integration ...$integrations) + { + $this->integrations = array_values($integrations); + } + + /** @return list */ + public function available(): array + { + return array_values(array_filter( + $this->integrations, + static fn (Integration $integration): bool => $integration->isAvailable() + )); + } + + /** @return list */ + public function abilities(): array + { + $abilities = []; + foreach ($this->available() as $integration) { + array_push($abilities, ...$integration->abilities()); + } + + return $abilities; + } + + /** @return list */ + public function directAbilityNames(): array + { + $names = []; + foreach ($this->abilities() as $ability) { + if ($ability->isDirect()) { + $names[] = $ability->name(); + } + } + + return $names; + } +} diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php new file mode 100644 index 0000000..cf7de1f --- /dev/null +++ b/tests/Unit/Abilities/AbilityRegistrarTest.php @@ -0,0 +1,116 @@ +allows('id')->andReturn('core'); + $integration->allows('isAvailable')->andReturn(true); + $integration->allows('abilities')->andReturn([$ability]); + + return new AbilityRegistrar(new IntegrationRegistry($integration)); + } + + private function ability(): Mockery\MockInterface + { + $ability = Mockery::mock(Ability::class); + $ability->allows('name')->andReturn('lit/test'); + $ability->allows('label')->andReturn('Test'); + $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('isDirect')->andReturn(true); + + return $ability; + } + + public function testArgsExposeAbilityPubliclyToMcpWithAnnotations(): void + { + $args = $this->registrarFor($this->ability())->argsFor($this->ability()); + + self::assertSame('Test', $args['label']); + self::assertSame(AbilityRegistrar::CATEGORY, $args['category']); + self::assertSame(['type' => 'object'], $args['output_schema']); + self::assertTrue($args['meta']['mcp']['public']); + self::assertSame(['readonly' => true], $args['meta']['annotations']); + self::assertFalse($args['meta']['show_in_rest']); + } + + public function testPermissionDeniedWithoutLitCapability(): void + { + Functions\when('current_user_can')->justReturn(false); + $ability = $this->ability(); + $ability->shouldNotReceive('permission'); + + $result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])([]); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('lit_forbidden', $result->get_error_code()); + } + + public function testPermissionDelegatesToAbilityWhenGatePasses(): void + { + Functions\when('current_user_can')->justReturn(true); + $ability = $this->ability(); + $ability->expects('permission')->with(['x' => 1])->andReturn(true); + + $result = ($this->registrarFor($ability)->argsFor($ability)['permission_callback'])(['x' => 1]); + + self::assertTrue($result); + } + + public function testNullInputIsPassedAsEmptyArray(): void + { + $ability = $this->ability(); + $ability->expects('execute')->with([])->andReturn(['ok' => true]); + + $result = ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])(null); + + self::assertSame(['ok' => true], $result); + } + + public function testExceptionBecomesNeutralInternalError(): void + { + // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + $ability = $this->ability(); + $ability->expects('execute')->andThrow(new RuntimeException('secret path /var/www/x.php')); + + try { + $result = ($this->registrarFor($ability)->argsFor($ability)['execute_callback'])([]); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('lit_internal_error', $result->get_error_code()); + self::assertStringNotContainsString('/var/www', $result->get_error_message()); + self::assertStringContainsString('lit/test failed', (string) file_get_contents($log)); + unlink($log); + } + + public function testRegisterAbilitiesCallsWordPressForEachAbility(): void + { + $ability = $this->ability(); + Functions\expect('wp_register_ability')->once()->with('lit/test', Mockery::type('array')); + + $this->registrarFor($ability)->registerAbilities(); + } +} diff --git a/tests/Unit/Integrations/IntegrationRegistryTest.php b/tests/Unit/Integrations/IntegrationRegistryTest.php new file mode 100644 index 0000000..eb015fe --- /dev/null +++ b/tests/Unit/Integrations/IntegrationRegistryTest.php @@ -0,0 +1,50 @@ +ability('lit/a', true); + $indirect = $this->ability('lit/b', false); + $hidden = $this->ability('lit/c', true); + + $registry = new IntegrationRegistry( + $this->integration('core', true, [$direct, $indirect]), + $this->integration('divi', false, [$hidden]) + ); + + self::assertSame(['core'], array_map(static fn ($i) => $i->id(), $registry->available())); + self::assertSame([$direct, $indirect], $registry->abilities()); + self::assertSame(['lit/a'], $registry->directAbilityNames()); + } + + private function ability(string $name, bool $direct): Ability + { + $ability = Mockery::mock(Ability::class); + $ability->allows('name')->andReturn($name); + $ability->allows('isDirect')->andReturn($direct); + + return $ability; + } + + /** @param list $abilities */ + private function integration(string $id, bool $available, array $abilities): Integration + { + $integration = Mockery::mock(Integration::class); + $integration->allows('id')->andReturn($id); + $integration->allows('isAvailable')->andReturn($available); + $integration->allows('abilities')->andReturn($abilities); + + return $integration; + } +}