From 2f8a9f0c618aff1c3960ae4bb0dbd5046d9e5c91 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 03:03:25 +0200 Subject: [PATCH] feat: configurable exposure policy for direct, indirect and disabled tools Introduces ExposurePolicy (option lit_mcp_exposure) deciding per ability whether it is a direct MCP tool, only reachable via mcp-adapter's execute-ability meta-tool, or disabled entirely (not registered with the Abilities API at all). Ability::isDirect() remains the default when no override applies. lit/guide can never be disabled or made indirect; lit/media-preview and lit/media-download (image/embedded-resource results) can never be made indirect, only direct or disabled. AbilityRegistrar and ServerRegistrar take an optional ExposurePolicy and use it to filter registered abilities / compute the direct tool list. IntegrationRegistry::directAbilityNames() is removed (superseded by ExposurePolicy::directNames()). InstructionsBuilder now also takes the policy and lists the currently direct tools by name (lit-), noting the rest are reachable via mcp-adapter-execute-ability as lit/. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- src/Abilities/AbilityRegistrar.php | 5 +- src/Abilities/ExposurePolicy.php | 122 ++++++++++++++++++ src/Guides/InstructionsBuilder.php | 18 ++- src/Integrations/IntegrationRegistry.php | 13 -- src/Plugin.php | 10 +- src/Server/ServerRegistrar.php | 9 +- tests/Unit/Abilities/AbilityRegistrarTest.php | 14 ++ tests/Unit/Abilities/ExposurePolicyTest.php | 116 +++++++++++++++++ tests/Unit/Guides/InstructionsBuilderTest.php | 52 +++++++- .../Integrations/IntegrationRegistryTest.php | 1 - tests/Unit/Server/ServerRegistrarTest.php | 28 ++++ 11 files changed, 362 insertions(+), 26 deletions(-) create mode 100644 src/Abilities/ExposurePolicy.php create mode 100644 tests/Unit/Abilities/ExposurePolicyTest.php diff --git a/src/Abilities/AbilityRegistrar.php b/src/Abilities/AbilityRegistrar.php index 01c6f78..6b14c6f 100644 --- a/src/Abilities/AbilityRegistrar.php +++ b/src/Abilities/AbilityRegistrar.php @@ -18,7 +18,7 @@ final class AbilityRegistrar { public const CATEGORY = 'lit-mcp'; - public function __construct(private IntegrationRegistry $registry) + public function __construct(private IntegrationRegistry $registry, private ?ExposurePolicy $policy = null) { } @@ -38,7 +38,8 @@ final class AbilityRegistrar public function registerAbilities(): void { - foreach ($this->registry->abilities() as $ability) { + $policy = $this->policy ?? new ExposurePolicy(); + foreach ($policy->enabled($this->registry->abilities()) as $ability) { wp_register_ability($ability->name(), $this->argsFor($ability)); } } diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php new file mode 100644 index 0000000..3f5443b --- /dev/null +++ b/src/Abilities/ExposurePolicy.php @@ -0,0 +1,122 @@ + $overrides Ability name => mode. */ + public function __construct(private array $overrides = []) + { + } + + public static function fromOption(): self + { + return new self(self::sanitize(get_option(self::OPTION, []))); + } + + public function mode(Ability $ability): string + { + $mode = $this->overrides[$ability->name()] ?? null; + + return is_string($mode) && self::isAllowed($ability->name(), $mode) ? $mode : $this->defaultMode($ability); + } + + public function defaultMode(Ability $ability): string + { + return $ability->isDirect() ? self::DIRECT : self::INDIRECT; + } + + /** + * @param list $abilities + * @return list + */ + public function enabled(array $abilities): array + { + return array_values(array_filter( + $abilities, + fn (Ability $ability): bool => $this->mode($ability) !== self::DISABLED + )); + } + + /** + * @param list $abilities + * @return list + */ + 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 (in_array($name, self::REQUIRED, true)) { + 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. + * + * @return array + */ + public static function sanitize(mixed $input): 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) + ) { + $clean[$name] = $mode; + } + } + ksort($clean); + + return $clean; + } +} diff --git a/src/Guides/InstructionsBuilder.php b/src/Guides/InstructionsBuilder.php index 3e3784a..8d092f3 100644 --- a/src/Guides/InstructionsBuilder.php +++ b/src/Guides/InstructionsBuilder.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Guides; +use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\IntegrationRegistry; @@ -16,9 +17,15 @@ final class InstructionsBuilder /** @var (callable(): list)|null */ private $postTypeNames; - public function __construct(private IntegrationRegistry $registry, ?callable $postTypeNames = null) - { + private ExposurePolicy $policy; + + public function __construct( + private IntegrationRegistry $registry, + ?callable $postTypeNames = null, + ?ExposurePolicy $policy = null + ) { $this->postTypeNames = $postTypeNames; + $this->policy = $policy ?? new ExposurePolicy(); } public function build(): string @@ -28,9 +35,16 @@ final class InstructionsBuilder $this->registry->available() )); + $directNames = array_map( + static fn (string $name): string => 'lit-' . substr($name, strlen('lit/')), + $this->policy->directNames($this->registry->abilities()) + ); + $lines = [ 'LIT MCP pflegt diese WordPress-Website (Inhalte, Medien, Divi 5, Pods).', 'Aktive Integrationen: ' . $active . '.', + 'Direkt aufrufbare Tools: ' . implode(', ', $directNames) . '.', + 'Weitere Fähigkeiten über mcp-adapter-execute-ability (Ability-Name lit/).', ]; if ($this->postTypeNames !== null) { diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php index 6364c0f..d9f7341 100644 --- a/src/Integrations/IntegrationRegistry.php +++ b/src/Integrations/IntegrationRegistry.php @@ -42,17 +42,4 @@ final class IntegrationRegistry return $this->abilities; } - - /** @return list */ - public function directAbilityNames(): array - { - $names = []; - foreach ($this->abilities() as $ability) { - if ($ability->isDirect()) { - $names[] = $ability->name(); - } - } - - return $names; - } } diff --git a/src/Plugin.php b/src/Plugin.php index f515a99..70f3e98 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP; use LIT\MCP\Abilities\AbilityRegistrar; +use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; @@ -54,10 +55,15 @@ final class Plugin new MediaIntegration($schema, $changeLog, $limits) ); - (new AbilityRegistrar($registry))->register(); + $policy = ExposurePolicy::fromOption(); + (new AbilityRegistrar($registry, $policy))->register(); (new ServerRegistrar( $registry, - new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames()) + new InstructionsBuilder($registry, static fn (): array => $schema->postTypeNames(), $policy), + null, + null, + null, + $policy ))->register(); \WPMedia\MCP\OAuth\Bootstrap::instance(); diff --git a/src/Server/ServerRegistrar.php b/src/Server/ServerRegistrar.php index e950559..7516852 100644 --- a/src/Server/ServerRegistrar.php +++ b/src/Server/ServerRegistrar.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Server; +use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Plugin; @@ -48,13 +49,17 @@ final class ServerRegistrar /** @var callable(string): bool */ private $classExists; + private ExposurePolicy $policy; + public function __construct( private IntegrationRegistry $registry, private InstructionsBuilder $instructions, ?callable $oauthEnabled = null, ?callable $createAdapterServer = null, - ?callable $classExists = null + ?callable $classExists = null, + ?ExposurePolicy $policy = null ) { + $this->policy = $policy ?? new ExposurePolicy(); $this->oauthEnabled = $oauthEnabled ?? static fn (): bool => (new OAuthContext())->is_enabled(); $this->classExists = $classExists @@ -102,7 +107,7 @@ final class ServerRegistrar 'name' => 'LIT MCP', 'description' => $this->instructions->build(), 'version' => Plugin::VERSION, - 'tools' => array_merge($this->registry->directAbilityNames(), self::META_TOOLS), + 'tools' => array_merge($this->policy->directNames($this->registry->abilities()), self::META_TOOLS), ]; } diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php index d58a19c..33b1ece 100644 --- a/tests/Unit/Abilities/AbilityRegistrarTest.php +++ b/tests/Unit/Abilities/AbilityRegistrarTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Abilities; use Brain\Monkey\Functions; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\AbilityRegistrar; +use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\IntegrationRegistry; @@ -185,4 +186,17 @@ final class AbilityRegistrarTest extends TestCase $this->registrarFor($ability)->registerAbilities(); } + + public function testDisabledAbilitiesAreNotRegistered(): void + { + $ability = $this->ability(); + $integration = Mockery::mock(Integration::class); + $integration->allows('id')->andReturn('core'); + $integration->allows('isAvailable')->andReturn(true); + $integration->allows('abilities')->andReturn([$ability]); + Functions\expect('wp_register_ability')->never(); + + (new AbilityRegistrar(new IntegrationRegistry($integration), new ExposurePolicy(['lit/test' => 'disabled']))) + ->registerAbilities(); + } } diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php new file mode 100644 index 0000000..e889cdb --- /dev/null +++ b/tests/Unit/Abilities/ExposurePolicyTest.php @@ -0,0 +1,116 @@ +allows('name')->andReturn($name); + $ability->allows('isDirect')->andReturn($direct); + + return $ability; + } + + public function testDefaultFollowsIsDirect(): void + { + $policy = new ExposurePolicy(); + + self::assertSame('direct', $policy->mode($this->ability('lit/content-get', true))); + self::assertSame('indirect', $policy->mode($this->ability('lit/menu-list', false))); + } + + public function testOverridesChangeTheMode(): void + { + $policy = new ExposurePolicy(['lit/content-get' => 'indirect', 'lit/menu-list' => 'disabled']); + + self::assertSame('indirect', $policy->mode($this->ability('lit/content-get', true))); + self::assertSame('disabled', $policy->mode($this->ability('lit/menu-list', false))); + } + + public function testImpossibleOrBrokenOverridesFallBackToTheDefault(): void + { + $policy = new ExposurePolicy([ + 'lit/media-preview' => 'indirect', + 'lit/media-download' => 'indirect', + 'lit/guide' => 'disabled', + 'lit/content-get' => 'sometimes', + ]); + + self::assertSame('direct', $policy->mode($this->ability('lit/media-preview', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/media-download', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/guide', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/content-get', true))); + } + + public function testGuideCanNeitherBeDisabledNorMadeIndirect(): void + { + $policy = new ExposurePolicy(['lit/guide' => 'indirect']); + + self::assertSame('direct', $policy->mode($this->ability('lit/guide', true))); + self::assertFalse(ExposurePolicy::isAllowed('lit/guide', 'indirect')); + self::assertFalse(ExposurePolicy::isAllowed('lit/guide', 'disabled')); + self::assertTrue(ExposurePolicy::isAllowed('lit/guide', 'direct')); + } + + public function testMediaPreviewAndDownloadMayStillBeDisabled(): void + { + $policy = new ExposurePolicy(['lit/media-preview' => 'disabled', 'lit/media-download' => 'disabled']); + + self::assertSame('disabled', $policy->mode($this->ability('lit/media-preview', true))); + self::assertSame('disabled', $policy->mode($this->ability('lit/media-download', true))); + } + + public function testEnabledAndDirectNamesKeepTheRegistryOrder(): void + { + $a = $this->ability('lit/a', true); + $b = $this->ability('lit/b', false); + $c = $this->ability('lit/c', true); + $policy = new ExposurePolicy(['lit/b' => 'direct', 'lit/c' => 'disabled']); + + self::assertSame([$a, $b], $policy->enabled([$a, $b, $c])); + self::assertSame(['lit/a', 'lit/b'], $policy->directNames([$a, $b, $c])); + } + + public function testSanitizeKeepsOnlyValidEntries(): void + { + self::assertSame( + ['lit/content-get' => 'indirect', 'lit/menu-list' => 'disabled'], + ExposurePolicy::sanitize([ + 'lit/menu-list' => 'disabled', + 'lit/content-get' => 'indirect', + 'lit/guide' => 'disabled', + '