diff --git a/src/Plugin.php b/src/Plugin.php index 70f3e98..9c48969 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -20,6 +20,7 @@ use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource; use LIT\MCP\Integrations\WordPress\WordPressIntegration; use LIT\MCP\Server\RequestContext; use LIT\MCP\Server\ServerRegistrar; +use LIT\MCP\Server\VisibilityFilter; /** * Wires WP-LIT-MCP into WordPress. @@ -42,6 +43,7 @@ final class Plugin (new Access())->register(); (new RequestContext())->register(); + (new VisibilityFilter())->register(); $limits = new Limits(); $changeLog = new ChangeLog(); diff --git a/src/Server/VisibilityFilter.php b/src/Server/VisibilityFilter.php new file mode 100644 index 0000000..6281c88 --- /dev/null +++ b/src/Server/VisibilityFilter.php @@ -0,0 +1,50 @@ +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(); + } +} diff --git a/tests/Integration/mcp-smoke.sh b/tests/Integration/mcp-smoke.sh index 684d2b3..84f24bc 100644 --- a/tests/Integration/mcp-smoke.sh +++ b/tests/Integration/mcp-smoke.sh @@ -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_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) + +# 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") 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 diff --git a/tests/Unit/Server/VisibilityFilterTest.php b/tests/Unit/Server/VisibilityFilterTest.php new file mode 100644 index 0000000..93b7106 --- /dev/null +++ b/tests/Unit/Server/VisibilityFilterTest.php @@ -0,0 +1,80 @@ +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(); + } +}