Tools tab shows one block per integration with a group mode (inherited by its tools) and a read-only switch that disables all non-readonly abilities of the group. ExposurePolicy resolves tool override > group override > tool default; lit/guide keeps its default, direct-only tools fall back to direct under a group "indirekt". Group entries live in lit_mcp_exposure under "@group:<id>" and "@readonly:<id>"; the old flat format stays valid. The ability->group map comes lazily from IntegrationRegistry (Divi is not loaded at plugins_loaded). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
262 lines
10 KiB
PHP
262 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Server;
|
||
|
||
use Brain\Monkey\Filters;
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\ExposurePolicy;
|
||
use LIT\MCP\Guides\InstructionsBuilder;
|
||
use LIT\MCP\Integrations\Integration;
|
||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||
use LIT\MCP\Server\ServerRegistrar;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WPMedia\MCP\OAuth\Transport\ServerRegistrar as OAuthServerRegistrar;
|
||
|
||
final class ServerRegistrarTest extends TestCase
|
||
{
|
||
protected function tearDown(): void
|
||
{
|
||
unset($GLOBALS['wp_filter']);
|
||
parent::tearDown();
|
||
}
|
||
|
||
private function registry(): IntegrationRegistry
|
||
{
|
||
$direct = Mockery::mock(Ability::class);
|
||
$direct->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<int, array<string, array{function: mixed, accepted_args: int}>> */
|
||
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<int, array<string, array{function: mixed, accepted_args: int}>> */
|
||
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<int, array<string, array{function: mixed, accepted_args: int}>> */
|
||
public array $callbacks = [];
|
||
};
|
||
$hook->callbacks = [10 => ['oauth-id' => ['function' => $oauthCallback, 'accepted_args' => 0]]];
|
||
$GLOBALS['wp_filter']['mcp_adapter_init'] = $hook;
|
||
|
||
/** @var list<string> $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');
|
||
}
|
||
}
|