feat: role gate with lit_mcp_use capability
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP;
|
||||||
|
|
||||||
|
use WP_User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role gate: only users whose role is allowed get the lit_mcp_use capability.
|
||||||
|
*
|
||||||
|
* The capability is granted dynamically via user_has_cap, so no role objects
|
||||||
|
* in the database are modified.
|
||||||
|
*/
|
||||||
|
final class Access
|
||||||
|
{
|
||||||
|
public const CAPABILITY = 'lit_mcp_use';
|
||||||
|
public const OPTION = 'lit_mcp_allowed_roles';
|
||||||
|
private const DEFAULT_ROLES = ['administrator'];
|
||||||
|
private const ADAPTER_META_TOOLS = [
|
||||||
|
'discover_abilities',
|
||||||
|
'get_ability_info',
|
||||||
|
'execute_ability',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
add_filter('user_has_cap', [$this, 'grantCapability'], 10, 4);
|
||||||
|
|
||||||
|
foreach (self::ADAPTER_META_TOOLS as $tool) {
|
||||||
|
add_filter(
|
||||||
|
"mcp_adapter_{$tool}_capability",
|
||||||
|
static fn (): string => self::CAPABILITY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function allowedRoles(): array
|
||||||
|
{
|
||||||
|
$roles = get_option(self::OPTION, self::DEFAULT_ROLES);
|
||||||
|
if (! is_array($roles)) {
|
||||||
|
$roles = self::DEFAULT_ROLES;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = apply_filters('lit_mcp_allowed_roles', $roles);
|
||||||
|
|
||||||
|
return array_values(array_filter($roles, 'is_string'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, bool> $allcaps
|
||||||
|
* @param list<string> $caps
|
||||||
|
* @param array<int, mixed> $args
|
||||||
|
* @return array<string, bool>
|
||||||
|
*/
|
||||||
|
public function grantCapability(
|
||||||
|
array $allcaps,
|
||||||
|
array $caps,
|
||||||
|
array $args,
|
||||||
|
WP_User $user
|
||||||
|
): array {
|
||||||
|
if (! in_array(self::CAPABILITY, $caps, true)) {
|
||||||
|
return $allcaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_intersect($user->roles, $this->allowedRoles()) !== []) {
|
||||||
|
$allcaps[self::CAPABILITY] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allcaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function currentUserMayUse(): bool
|
||||||
|
{
|
||||||
|
return current_user_can(self::CAPABILITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ final class Plugin
|
|||||||
self::noticeMissing($missing);
|
self::noticeMissing($missing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(new Access())->register();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function pluginDir(): string
|
public static function pluginDir(): string
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit;
|
||||||
|
|
||||||
|
use Brain\Monkey\Filters;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Access;
|
||||||
|
use WP_User;
|
||||||
|
|
||||||
|
final class AccessTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDefaultsToAdministratorOnly(): void
|
||||||
|
{
|
||||||
|
Functions\expect('get_option')
|
||||||
|
->once()
|
||||||
|
->with(Access::OPTION, ['administrator'])
|
||||||
|
->andReturn(['administrator']);
|
||||||
|
|
||||||
|
self::assertSame(['administrator'], (new Access())->allowedRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRoleListIsFilterable(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_option')->justReturn(['administrator']);
|
||||||
|
Filters\expectApplied('lit_mcp_allowed_roles')
|
||||||
|
->once()
|
||||||
|
->andReturn(['administrator', 'editor']);
|
||||||
|
|
||||||
|
self::assertSame(['administrator', 'editor'], (new Access())->allowedRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGrantsCapabilityToAllowedRole(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_option')->justReturn(['editor']);
|
||||||
|
|
||||||
|
$caps = (new Access())->grantCapability(
|
||||||
|
[],
|
||||||
|
[Access::CAPABILITY],
|
||||||
|
[],
|
||||||
|
new WP_User(5, ['editor'])
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($caps[Access::CAPABILITY]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoesNotGrantCapabilityToOtherRoles(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_option')->justReturn(['administrator']);
|
||||||
|
|
||||||
|
$caps = (new Access())->grantCapability(
|
||||||
|
[],
|
||||||
|
[Access::CAPABILITY],
|
||||||
|
[],
|
||||||
|
new WP_User(7, ['subscriber'])
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertArrayNotHasKey(Access::CAPABILITY, $caps);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCorruptOptionFallsBackToAdministrator(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_option')->justReturn('not-an-array');
|
||||||
|
|
||||||
|
self::assertSame(['administrator'], (new Access())->allowedRoles());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegisterRoutesAdapterMetaToolCapabilitiesToOwnCapability(): void
|
||||||
|
{
|
||||||
|
// Brain Monkey records add_filter() but never runs callbacks, so we
|
||||||
|
// capture each registered callback and invoke it ourselves.
|
||||||
|
foreach (['discover_abilities', 'get_ability_info', 'execute_ability'] as $tool) {
|
||||||
|
Filters\expectAdded("mcp_adapter_{$tool}_capability")
|
||||||
|
->once()
|
||||||
|
->whenHappen(static function (callable $callback): void {
|
||||||
|
self::assertSame(Access::CAPABILITY, $callback('read'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$access = new Access();
|
||||||
|
$access->register();
|
||||||
|
|
||||||
|
self::assertNotFalse(has_filter('user_has_cap', [$access, 'grantCapability']));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user