feat: hide tool list and instructions from users without LIT MCP access

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:
2026-09-26 03:13:05 +02:00
co-authored by Claude Opus 5.5
parent 2f8a9f0c61
commit 2674bf2ed1
4 changed files with 150 additions and 0 deletions
+2
View File
@@ -20,6 +20,7 @@ use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
use LIT\MCP\Integrations\WordPress\WordPressIntegration; use LIT\MCP\Integrations\WordPress\WordPressIntegration;
use LIT\MCP\Server\RequestContext; use LIT\MCP\Server\RequestContext;
use LIT\MCP\Server\ServerRegistrar; use LIT\MCP\Server\ServerRegistrar;
use LIT\MCP\Server\VisibilityFilter;
/** /**
* Wires WP-LIT-MCP into WordPress. * Wires WP-LIT-MCP into WordPress.
@@ -42,6 +43,7 @@ final class Plugin
(new Access())->register(); (new Access())->register();
(new RequestContext())->register(); (new RequestContext())->register();
(new VisibilityFilter())->register();
$limits = new Limits(); $limits = new Limits();
$changeLog = new ChangeLog(); $changeLog = new ChangeLog();
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Server;
use LIT\MCP\Access;
use WP\MCP\Core\McpServer;
use WP\McpSchema\Common\Protocol\DTO\InitializeResult;
/**
* Users who may connect (mcp-oauth) but are not allowed to use LIT MCP get neither
* the tool list nor the site-specific instructions of our server. Execution is
* denied anyway (Access + AbilityRegistrar); this only avoids leaking details.
*/
final class VisibilityFilter
{
public const NO_ACCESS_INSTRUCTIONS = 'Dein WordPress-Benutzerkonto ist für LIT MCP nicht freigegeben. '
. 'Ein Administrator kann deine Rolle unter Einstellungen → LIT MCP → Zugriff freigeben.';
public function register(): void
{
add_filter('mcp_adapter_tools_list', [$this, 'filterTools'], 10, 2);
add_filter('mcp_adapter_initialize_response', [$this, 'filterInitialize'], 10, 2);
}
public function filterTools(mixed $tools, mixed $server): mixed
{
return $this->hides($server) ? [] : $tools;
}
public function filterInitialize(mixed $result, mixed $server): mixed
{
if (! $result instanceof InitializeResult || ! $this->hides($server)) {
return $result;
}
$data = $result->toArray();
$data['instructions'] = self::NO_ACCESS_INSTRUCTIONS;
return InitializeResult::fromArray($data);
}
private function hides(mixed $server): bool
{
return $server instanceof McpServer
&& $server->get_server_id() === ServerRegistrar::SERVER_ID
&& ! Access::currentUserMayUse();
}
}
+18
View File
@@ -120,6 +120,24 @@ wp user create lit-smoke-sub lit-smoke-sub@example.invalid --role=subscriber >/d
SUB_TOKEN=$(mint_token lit-smoke-sub) SUB_TOKEN=$(mint_token lit-smoke-sub)
SUB_INIT=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}') SUB_INIT=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}')
SUB_SESSION=$(echo "$SUB_INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true) SUB_SESSION=$(echo "$SUB_INIT" | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r' || true)
# 5a. Visibility (Task 7): VisibilityFilter hides our server's tool list and
# instructions from a subscriber entirely, before AbilityRegistrar's own
# per-call deny below is even reached. tools/list comes back empty (no direct
# tools, no meta-tools either – the adapter still accepts an empty array, see
# HandlerHelperTrait::validate_filtered_list); instructions carry only the
# no-access notice, never integration names or post types.
echo "$SUB_INIT" | grep -q 'nicht freigegeben' \
|| fail "subscriber initialize instructions lack the no-access notice: $SUB_INIT"
echo "$SUB_INIT" | grep -Eq 'Inhaltstypen|Divi|Pods|Aktive Integrationen|Direkt aufrufbare Tools' \
&& fail "subscriber initialize instructions leak integration/post-type details: $SUB_INIT"
pass "subscriber initialize instructions carry only the no-access notice"
SUB_LIST=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":10,"method":"tools/list"}' "$SUB_SESSION")
echo "$SUB_LIST" | grep -q '"tools":\[\]' \
|| fail "subscriber tools/list is not empty: $SUB_LIST"
pass "subscriber tools/list is empty (no direct tools, no meta-tools)"
SUB_CALL=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SUB_SESSION") SUB_CALL=$(mcp "$SUB_TOKEN" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lit-site-status","arguments":{}}}' "$SUB_SESSION")
echo "$SUB_CALL" | grep -Eq '"isError":[[:space:]]*true|"error":[[:space:]]*\{' || fail "subscriber could call lit-site-status: $SUB_CALL" echo "$SUB_CALL" | grep -Eq '"isError":[[:space:]]*true|"error":[[:space:]]*\{' || fail "subscriber could call lit-site-status: $SUB_CALL"
# The denial text must actually be our role-gate message, not just any error # The denial text must actually be our role-gate message, not just any error
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Server;
use Brain\Monkey\Functions;
use LIT\MCP\Server\VisibilityFilter;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP\MCP\Core\McpServer;
use WP\McpSchema\Common\Protocol\DTO\InitializeResult;
final class VisibilityFilterTest extends TestCase
{
private function server(string $id): McpServer
{
$server = Mockery::mock(McpServer::class);
$server->allows('get_server_id')->andReturn($id);
return $server;
}
private function initializeResult(): InitializeResult
{
return InitializeResult::fromArray([
'protocolVersion' => '2025-11-25',
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'LIT MCP', 'version' => '0.2.0'],
'instructions' => 'Inhaltstypen: page, post, geheim',
]);
}
public function testUsersWithoutAccessSeeNoToolsOnOurServer(): void
{
Functions\when('current_user_can')->justReturn(false);
$filter = new VisibilityFilter();
self::assertSame([], $filter->filterTools(['tool-a'], $this->server('mcp-oauth-server')));
self::assertSame(['tool-a'], $filter->filterTools(['tool-a'], $this->server('other-server')));
}
public function testAllowedUsersKeepTheToolList(): void
{
Functions\when('current_user_can')->justReturn(true);
self::assertSame(['tool-a'], (new VisibilityFilter())->filterTools(['tool-a'], $this->server('mcp-oauth-server')));
}
public function testInstructionsAreReplacedForUsersWithoutAccess(): void
{
Functions\when('current_user_can')->justReturn(false);
$result = (new VisibilityFilter())->filterInitialize($this->initializeResult(), $this->server('mcp-oauth-server'));
self::assertInstanceOf(InitializeResult::class, $result);
self::assertSame(VisibilityFilter::NO_ACCESS_INSTRUCTIONS, $result->getInstructions());
self::assertSame('2025-11-25', $result->getProtocolVersion());
}
public function testInstructionsStayForAllowedUsersAndOtherServers(): void
{
Functions\when('current_user_can')->justReturn(true);
$original = $this->initializeResult();
self::assertSame($original, (new VisibilityFilter())->filterInitialize($original, $this->server('mcp-oauth-server')));
Functions\when('current_user_can')->justReturn(false);
self::assertSame($original, (new VisibilityFilter())->filterInitialize($original, $this->server('other-server')));
}
public function testRegisterHooksBothFilters(): void
{
$filter = new VisibilityFilter();
Functions\expect('add_filter')->once()->with('mcp_adapter_tools_list', [$filter, 'filterTools'], 10, 2);
Functions\expect('add_filter')->once()->with('mcp_adapter_initialize_response', [$filter, 'filterInitialize'], 10, 2);
$filter->register();
}
}