allows('name')->andReturn('lit/guide'); $direct->allows('isDirect')->andReturn(true); $indirect = Mockery::mock(Ability::class); $indirect->allows('name')->andReturn('lit/rare-tool'); $indirect->allows('isDirect')->andReturn(false); $indirect->allows('annotations')->andReturn([]); $core = Mockery::mock(Integration::class); $core->allows('id')->andReturn('core'); $core->allows('isAvailable')->andReturn(true); $core->allows('abilities')->andReturn([$direct, $indirect]); return new IntegrationRegistry($core); } private function instructions(): InstructionsBuilder { // InstructionsBuilder is final (not mockable) and pure – use the real one. return new InstructionsBuilder($this->registry()); } public function testServerUsesOauthRouteDirectToolsPlusMetaToolsAndInstructions(): void { $args = (new ServerRegistrar($this->registry(), $this->instructions(), static fn () => true))->serverArguments(); self::assertSame('mcp-oauth-server', $args['id']); self::assertSame('mcp', $args['namespace']); self::assertSame('mcp-oauth-server', $args['route']); self::assertStringContainsString('Aktive Integrationen: core', $args['description']); self::assertSame( ['lit/guide', 'mcp-adapter/discover-abilities', 'mcp-adapter/get-ability-info', 'mcp-adapter/execute-ability'], $args['tools'] ); self::assertNotContains('lit/rare-tool', $args['tools']); } public function testExposurePolicyDecidesWhichToolsAreDirect(): void { // Deviation from the brief: the brief's original test demoted lit/guide to // indirect, but the controller's fixed constraint for this task is that // lit/guide can neither be disabled nor made indirect (see ExposurePolicy:: // REQUIRED / ExposurePolicyTest::testGuideCanNeitherBeDisabledNorMadeIndirect). // Using a demotable direct ability here instead proves the same thing: the // policy, not Ability::isDirect(), decides the tool list. $optional = Mockery::mock(Ability::class); $optional->allows('name')->andReturn('lit/optional-tool'); $optional->allows('isDirect')->andReturn(true); $core = Mockery::mock(Integration::class); $core->allows('id')->andReturn('core'); $core->allows('isAvailable')->andReturn(true); $core->allows('abilities')->andReturn([$optional]); $registry = new IntegrationRegistry($core, ...$this->registry()->available()); $policy = new ExposurePolicy(['lit/optional-tool' => 'indirect', 'lit/rare-tool' => 'direct']); $registrar = new ServerRegistrar($registry, $this->instructions(), static fn () => true, null, null, $policy); self::assertSame( ['lit/guide', 'lit/rare-tool', 'mcp-adapter/discover-abilities', 'mcp-adapter/get-ability-info', 'mcp-adapter/execute-ability'], $registrar->serverArguments()['tools'] ); } public function testGroupSettingsReachTheToolListAndTheInstructions(): void { $registry = $this->registry(); $policy = (new ExposurePolicy(['@group:core' => 'disabled', '@readonly:core' => true])) ->withGroups($registry->groupMap()); $instructions = new InstructionsBuilder($registry, null, $policy); $args = (new ServerRegistrar($registry, $instructions, static fn () => true, null, null, $policy)) ->serverArguments(); // lit/guide is REQUIRED and stays direct; lit/rare-tool is disabled by the group. self::assertSame( ['lit/guide', 'mcp-adapter/discover-abilities', 'mcp-adapter/get-ability-info', 'mcp-adapter/execute-ability'], $args['tools'] ); self::assertStringContainsString('Direkt aufrufbare Tools: lit-guide.', $args['description']); } public function testNoServerWhenOauthIsDisabled(): void { $registrar = new ServerRegistrar($this->registry(), $this->instructions(), static fn () => false); self::assertNull($registrar->serverArguments()); } public function testCreateServerOutsideAdapterInitDoesNothing(): void { Functions\when('doing_action')->justReturn(false); $oauthChecked = false; $registrar = new ServerRegistrar( $this->registry(), $this->instructions(), static function () use (&$oauthChecked): bool { $oauthChecked = true; return true; } ); $registrar->createServer(); self::assertFalse($oauthChecked, 'createServer must bail out before touching the adapter'); } public function testRegisterDisablesAdapterDefaultServerAndHooksCreateServer(): void { $registrar = new ServerRegistrar($this->registry(), $this->instructions()); Functions\expect('add_filter') ->once() ->with('mcp_adapter_create_default_server', '__return_false'); Functions\expect('add_action') ->once() ->with('mcp_adapter_init', [$registrar, 'createServer'], 5); $registrar->register(); } public function testDefaultOauthEnabledDelegatesToMcpOauthContext(): void { // LIT\MCP\Server\ServerRegistrar::__construct() defaults to // (new \WPMedia\MCP\OAuth\Context())->is_enabled() – exercise that real // Context, only stubbing the filter it reads, to prove we track its notion // of "enabled" (including the deprecated rocket_mcp_oauth_server_enabled // filter Context::is_enabled() also seeds from) instead of duplicating it. Filters\expectApplied('wpmedia_mcp_oauth_server_enabled')->once()->andReturn(false); $registrar = new ServerRegistrar($this->registry(), $this->instructions()); self::assertNull($registrar->serverArguments()); } public function testUnhookOAuthDefaultServerRemovesOnlyTheMcpOauthCallback(): void { $oauthRegistrar = Mockery::mock(OAuthServerRegistrar::class); $oauthCallback = [$oauthRegistrar, 'register_server']; $unrelatedCallback = static function (): void { }; $hook = new class { /** @var array> */ public array $callbacks = []; }; $hook->callbacks = [ 5 => ['unrelated-id' => ['function' => $unrelatedCallback, 'accepted_args' => 0]], 10 => ['oauth-id' => ['function' => $oauthCallback, 'accepted_args' => 0]], ]; $GLOBALS['wp_filter']['mcp_adapter_init'] = $hook; Functions\expect('remove_action') ->once() ->with('mcp_adapter_init', $oauthCallback, 10); (new ServerRegistrar($this->registry(), $this->instructions()))->unhookOAuthDefaultServer(); } public function testUnhookOAuthDefaultServerIsNoOpWhenHookIsMissing(): void { unset($GLOBALS['wp_filter']['mcp_adapter_init']); Functions\expect('remove_action')->never(); (new ServerRegistrar($this->registry(), $this->instructions()))->unhookOAuthDefaultServer(); } public function testUnhookOAuthDefaultServerIsNoOpWhenOAuthPackageIsAbsent(): void { $oauthRegistrar = Mockery::mock(OAuthServerRegistrar::class); $oauthCallback = [$oauthRegistrar, 'register_server']; $hook = new class { /** @var array> */ public array $callbacks = []; }; $hook->callbacks = [10 => ['oauth-id' => ['function' => $oauthCallback, 'accepted_args' => 0]]]; $GLOBALS['wp_filter']['mcp_adapter_init'] = $hook; Functions\expect('remove_action')->never(); // Simulate wp-media/mcp-oauth not being installed via the injectable // classExists probe (class_exists() is a PHP internal and cannot be // stubbed directly through Brain\Monkey/Patchwork). $registrar = new ServerRegistrar( $this->registry(), $this->instructions(), null, null, static fn (): bool => false ); $registrar->unhookOAuthDefaultServer(); } public function testCreateServerUnhooksOAuthDefaultServerBeforeCreatingAdapterServer(): void { Functions\when('doing_action')->justReturn(true); Functions\when('is_wp_error')->justReturn(false); $oauthRegistrar = Mockery::mock(OAuthServerRegistrar::class); $oauthCallback = [$oauthRegistrar, 'register_server']; $hook = new class { /** @var array> */ public array $callbacks = []; }; $hook->callbacks = [10 => ['oauth-id' => ['function' => $oauthCallback, 'accepted_args' => 0]]]; $GLOBALS['wp_filter']['mcp_adapter_init'] = $hook; /** @var list $order */ $order = []; Functions\expect('remove_action') ->once() ->with('mcp_adapter_init', $oauthCallback, 10) ->andReturnUsing(static function () use (&$order): void { $order[] = 'unhook'; }); $registrar = new ServerRegistrar( $this->registry(), $this->instructions(), static fn (): bool => true, static function (array $args) use (&$order): bool { $order[] = 'create'; return true; } ); $registrar->createServer(); self::assertSame(['unhook', 'create'], $order, 'unhook must run before the adapter creates the server'); } }