From 673273a250d1597be181d5c6c33d25aafed8d0f8 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Thu, 28 May 2026 16:43:13 +0200 Subject: [PATCH] Add McpToolsProvider with addTools/filterTools events --- AGENTS.md | 1 + CHANGELOG.md | 4 + McpServerFactory.php | 92 +------ McpToolsProvider.php | 193 +++++++++++++++ McpToolsProviderInterface.php | 31 +++ config/config.php | 3 + tests/Framework/FixedMcpToolsProvider.php | 42 ++++ tests/Framework/StubMcpTool.php | 59 +++++ .../McpToolRegistrationEventTest.php | 81 ++++++ tests/Unit/APITest.php | 7 +- tests/Unit/McpServerFactoryTest.php | 40 ++- tests/Unit/McpToolsProviderTest.php | 234 ++++++++++++++++++ tests/Unit/RawApiToolVisibilityTest.php | 4 +- 13 files changed, 688 insertions(+), 103 deletions(-) create mode 100644 McpToolsProvider.php create mode 100644 McpToolsProviderInterface.php create mode 100644 tests/Framework/FixedMcpToolsProvider.php create mode 100644 tests/Framework/StubMcpTool.php create mode 100644 tests/Integration/McpToolRegistrationEventTest.php create mode 100644 tests/Unit/McpToolsProviderTest.php diff --git a/AGENTS.md b/AGENTS.md index fd5d6513..30b814f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ This document is repo-specific. Prefer it over generic Matomo assumptions when w - `API.php`: main MCP API endpoint, request validation flow, auth/error handling, and hand-off into the server. - `McpServer.php`: plugin lifecycle hooks, stylesheet registration, install/uninstall table setup. - `McpServerFactory.php`: server/container wiring and MCP server construction. +- `McpToolsProvider.php`: resolves the built-in tool set and fires the `McpServer.addTools` / `McpServer.filterTools` events so other plugins can register or restrict MCP tools. - `SystemSettings.php`: plugin settings, including MCP enablement. - `plugin.json`, `composer.json`: plugin metadata and PHP dependency constraints. diff --git a/CHANGELOG.md b/CHANGELOG.md index 55c2cdb6..ec4558f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Changelog +### Unreleased + +- Added `McpServer.addTools` and `McpServer.filterTools` events so other plugins can contribute or restrict MCP tool registrations. + ### 5.0.4 - Updated `mcp/sdk` to 0.5. diff --git a/McpServerFactory.php b/McpServerFactory.php index e028160b..54299038 100644 --- a/McpServerFactory.php +++ b/McpServerFactory.php @@ -26,25 +26,6 @@ use Piwik\Plugins\McpServer\Contracts\McpTool; use Piwik\Plugins\McpServer\Contracts\McpToolAnnotations; use Piwik\Plugins\McpServer\Contracts\McpToolIcon; -use Piwik\Plugins\McpServer\McpTools\ApiCallCreate; -use Piwik\Plugins\McpServer\McpTools\ApiCallDelete; -use Piwik\Plugins\McpServer\McpTools\ApiCallFull; -use Piwik\Plugins\McpServer\McpTools\ApiCallRead; -use Piwik\Plugins\McpServer\McpTools\ApiCallUpdate; -use Piwik\Plugins\McpServer\McpTools\ApiGet; -use Piwik\Plugins\McpServer\McpTools\ApiList; -use Piwik\Plugins\McpServer\McpTools\DimensionGet; -use Piwik\Plugins\McpServer\McpTools\DimensionList; -use Piwik\Plugins\McpServer\McpTools\GoalGet; -use Piwik\Plugins\McpServer\McpTools\GoalList; -use Piwik\Plugins\McpServer\McpTools\ReportList; -use Piwik\Plugins\McpServer\McpTools\ReportMetadata; -use Piwik\Plugins\McpServer\McpTools\ReportProcessed; -use Piwik\Plugins\McpServer\McpTools\SegmentGet; -use Piwik\Plugins\McpServer\McpTools\SegmentList; -use Piwik\Plugins\McpServer\McpTools\SiteGet; -use Piwik\Plugins\McpServer\McpTools\SiteList; -use Piwik\Plugins\McpServer\McpTools\SiteSearch; use Piwik\Plugins\McpServer\Server\Handler\Request\CompatibleCallToolHandler; use Piwik\Plugins\McpServer\Server\Handler\Request\ObservedCallToolHandler; use Piwik\Plugins\McpServer\Server\InternalAccess; @@ -58,34 +39,6 @@ final class McpServerFactory /** @var array */ private const VALID_TOOL_CALL_LOG_LEVELS = ['ERROR', 'WARN', 'WARNING', 'INFO', 'DEBUG', 'VERBOSE']; - /** - * Tools shipped by this plugin. Held as class-strings so DI/container - * construction stays in one place — see buildBuiltinTools(). - * - * @var list> - */ - private const BUILTIN_TOOL_CLASSES = [ - ApiCallCreate::class, - ApiCallDelete::class, - ApiCallFull::class, - ApiCallRead::class, - ApiCallUpdate::class, - ApiGet::class, - ApiList::class, - DimensionGet::class, - DimensionList::class, - GoalGet::class, - GoalList::class, - ReportList::class, - ReportMetadata::class, - ReportProcessed::class, - SegmentGet::class, - SegmentList::class, - SiteGet::class, - SiteList::class, - SiteSearch::class, - ]; - /** @var array{server: Server, registry: Registry, callToolHandler: RequestHandlerInterface}|null */ private ?array $runtimeCache = null; @@ -94,26 +47,12 @@ public function __construct( private SessionStoreInterface $sessionStore, private ContainerInterface $container, private ToolCallParameterFormatter $toolCallParameterFormatter, + private McpToolsProviderInterface $toolsProvider, ) { } - /** - * @param list|null $tools Built-in tools to register. When null, - * the factory resolves the shipped tool - * set via its container. Tests inject an - * explicit list to bypass DI. - * An explicit list bypasses the runtime - * cache and always builds a fresh server; - * it is never written back to the cache, - * so production calls (which pass null) - * keep seeing the shipped tool set. - */ - public function createServer(?array $tools = null): Server + public function createServer(): Server { - if ($tools !== null) { - return $this->buildRuntime($tools)['server']; - } - return $this->resolveRuntime()['server']; } @@ -165,12 +104,11 @@ private function resolveRuntime(): array } /** - * @param list|null $tools * @return array{server: Server, registry: Registry, callToolHandler: RequestHandlerInterface} */ - private function buildRuntime(?array $tools = null): array + private function buildRuntime(): array { - $tools ??= $this->buildBuiltinTools(); + $tools = $this->toolsProvider->getAllTools(); $version = (string) Manager::getInstance()->getVersion('McpServer'); $loggingConfig = $this->resolveLoggingConfig(); @@ -283,28 +221,6 @@ private function adaptIcons(?array $icons): ?array ); } - /** - * Resolve the built-in tool class-strings into instances. - * - * @return list - */ - private function buildBuiltinTools(): array - { - $tools = []; - foreach (self::BUILTIN_TOOL_CLASSES as $toolClass) { - $tool = $this->container->get($toolClass); - if (!$tool instanceof McpTool) { - throw new \LogicException(sprintf( - '%s did not resolve to an McpTool instance.', - $toolClass, - )); - } - $tools[] = $tool; - } - - return $tools; - } - /** * @return array{logToolCalls: bool, logFullParameters: bool, logLevel: string} */ diff --git a/McpToolsProvider.php b/McpToolsProvider.php new file mode 100644 index 00000000..7626416d --- /dev/null +++ b/McpToolsProvider.php @@ -0,0 +1,193 @@ +> + */ + private const BUILTIN_TOOL_CLASSES = [ + ApiCallCreate::class, + ApiCallDelete::class, + ApiCallFull::class, + ApiCallRead::class, + ApiCallUpdate::class, + ApiGet::class, + ApiList::class, + DimensionGet::class, + DimensionList::class, + GoalGet::class, + GoalList::class, + ReportList::class, + ReportMetadata::class, + ReportProcessed::class, + SegmentGet::class, + SegmentList::class, + SiteGet::class, + SiteList::class, + SiteSearch::class, + ]; + + public function __construct( + private ContainerInterface $container, + private EventDispatcher $eventDispatcher, + ) { + } + + /** + * Return all MCP tools that should be considered for registration. + * + * The list begins with the McpServer built-in tools, lets other plugins + * append their own via {@hook McpServer.addTools}, then lets plugins + * restrict the result via {@hook McpServer.filterTools}. + * + * Each entry MUST be an instance of {@see McpTool}; entries that fail + * this check trigger a LogicException so misconfigurations surface at + * boot rather than at tool-call time. + * + * @return list + */ + public function getAllTools(): array + { + // Listeners receive the list by reference and can replace it with any + // value, so validate the result at boot — both that it is still an + // array and that every entry is an McpTool — rather than failing later + // at tool-call time. + $contributed = $this->dispatchToolEvents($this->resolveBuiltinTools()); + if (!is_array($contributed)) { + throw new \LogicException(sprintf( + 'McpServer tool list must be an array, got %s.', + get_debug_type($contributed), + )); + } + + $result = []; + foreach ($contributed as $tool) { + if (!$tool instanceof McpTool) { + throw new \LogicException(sprintf( + 'McpServer tool list contained a non-McpTool entry: %s.', + get_debug_type($tool), + )); + } + $result[] = $tool; + } + + return $result; + } + + /** + * Pass the built-in tool list through the add/filter events and return + * whatever the listeners left behind. + * + * Listeners mutate the list by reference and are not constrained by the + * static type system, so the return type is intentionally `mixed`; the + * caller validates it before use. + * + * @param list $tools + * @return mixed + */ + private function dispatchToolEvents(array $tools) + { + /** + * Triggered to add custom MCP tools that ship outside the McpServer + * plugin. Tools added here are subsequently passed through the + * {@hook McpServer.filterTools} event before being registered. + * + * **Example** + * + * public function addMcpTools(&$tools) + * { + * $tools[] = StaticContainer::get(MyCustomMcpTool::class); + * } + * + * @param McpTool[] &$tools An array of MCP tool instances. + */ + $this->eventDispatcher->postEvent('McpServer.addTools', [&$tools]); + + /** + * Triggered to filter or restrict the set of MCP tools that will be + * registered with the server. Use this to hide tools conditionally + * (for example based on plugin state or system settings). + * + * **Example** + * + * public function filterMcpTools(&$tools) + * { + * foreach ($tools as $index => $tool) { + * if ($tool->getName() === 'matomo_site_list') { + * unset($tools[$index]); + * } + * } + * } + * + * @param McpTool[] &$tools An array of MCP tool instances. + */ + $this->eventDispatcher->postEvent('McpServer.filterTools', [&$tools]); + + return $tools; + } + + /** + * @return list + */ + private function resolveBuiltinTools(): array + { + $tools = []; + foreach (self::BUILTIN_TOOL_CLASSES as $toolClass) { + $tool = $this->container->get($toolClass); + if (!$tool instanceof McpTool) { + throw new \LogicException(sprintf( + '%s did not resolve to an McpTool instance.', + $toolClass, + )); + } + $tools[] = $tool; + } + + return $tools; + } +} diff --git a/McpToolsProviderInterface.php b/McpToolsProviderInterface.php new file mode 100644 index 00000000..fcb99cb9 --- /dev/null +++ b/McpToolsProviderInterface.php @@ -0,0 +1,31 @@ + + */ + public function getAllTools(): array; +} diff --git a/config/config.php b/config/config.php index c8a9d97f..57a12cf5 100644 --- a/config/config.php +++ b/config/config.php @@ -29,6 +29,8 @@ use Piwik\Plugins\McpServer\Contracts\Ports\Sites\SiteDetailQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\Sites\SiteSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; +use Piwik\Plugins\McpServer\McpToolsProvider; +use Piwik\Plugins\McpServer\McpToolsProviderInterface; use Piwik\Plugins\McpServer\Services\Api\ApiCallQueryService; use Piwik\Plugins\McpServer\Services\Api\ApiMethodSummaryQueryService; use Piwik\Plugins\McpServer\Services\Api\CoreApiCallGateway; @@ -68,6 +70,7 @@ DimensionSummaryQueryServiceInterface::class => DI::autowire(DimensionSummaryQueryService::class), GoalDetailQueryServiceInterface::class => DI::autowire(GoalDetailQueryService::class), GoalSummaryQueryServiceInterface::class => DI::autowire(GoalSummaryQueryService::class), + McpToolsProviderInterface::class => DI::autowire(McpToolsProvider::class), PluginCapabilityGatewayInterface::class => DI::autowire(PluginCapabilityGateway::class), ReportMetadataQueryServiceInterface::class => DI::autowire(ReportMetadataQueryService::class), ReportProcessedQueryServiceInterface::class => DI::autowire(ReportProcessedQueryService::class), diff --git a/tests/Framework/FixedMcpToolsProvider.php b/tests/Framework/FixedMcpToolsProvider.php new file mode 100644 index 00000000..f7121867 --- /dev/null +++ b/tests/Framework/FixedMcpToolsProvider.php @@ -0,0 +1,42 @@ + $tools + */ + public function __construct(private array $tools) + { + } + + /** + * @return list + */ + public function getAllTools(): array + { + return $this->tools; + } +} diff --git a/tests/Framework/StubMcpTool.php b/tests/Framework/StubMcpTool.php new file mode 100644 index 00000000..440027bd --- /dev/null +++ b/tests/Framework/StubMcpTool.php @@ -0,0 +1,59 @@ +name = $this->toolName; + $this->description = 'Test-only MCP tool contributed through the McpServer tool events.'; + $this->annotations = new McpToolAnnotations( + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + ); + $this->inputSchema = [ + 'type' => 'object', + 'properties' => [ + 'value' => [ + 'type' => 'string', + 'description' => 'Ignored test parameter.', + ], + ], + 'additionalProperties' => false, + ]; + } + + /** + * @return array + */ + public function execute(?string $value = null): array + { + return ['value' => $value]; + } +} diff --git a/tests/Integration/McpToolRegistrationEventTest.php b/tests/Integration/McpToolRegistrationEventTest.php new file mode 100644 index 00000000..8e58f918 --- /dev/null +++ b/tests/Integration/McpToolRegistrationEventTest.php @@ -0,0 +1,81 @@ +listToolNames(); + + self::assertContains(self::CONTRIBUTED_TOOL_NAME, $toolNames); + // The contributed tool is added alongside the built-in tools, not in place of them. + self::assertContains(SiteList::TOOL_NAME, $toolNames); + } + + public function testFilterToolsEventHidesContributedTool(): void + { + Piwik::addAction('McpServer.addTools', function (array &$tools): void { + $tools[] = new StubMcpTool(self::CONTRIBUTED_TOOL_NAME); + }); + Piwik::addAction('McpServer.filterTools', static function (array &$tools): void { + $tools = array_values(array_filter( + $tools, + static fn(McpTool $tool): bool => $tool->getName() !== self::CONTRIBUTED_TOOL_NAME, + )); + }); + + $toolNames = $this->listToolNames(); + + self::assertNotContains(self::CONTRIBUTED_TOOL_NAME, $toolNames); + // Filtering out one tool must leave the built-in set intact. + self::assertContains(SiteList::TOOL_NAME, $toolNames); + } + + /** + * @return list + */ + private function listToolNames(): array + { + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeListToolsRequest('list-tools-1'); + + $response = McpTestHelper::postJson($server, $payload, ['Mcp-Session-Id' => $sessionId]); + $message = McpTestHelper::decodeResponse($response); + $result = McpTestHelper::parseListTools($message); + + return array_values(array_map(static fn($tool): string => $tool->name, $result->tools)); + } +} diff --git a/tests/Unit/APITest.php b/tests/Unit/APITest.php index 57f67910..17ca8f10 100644 --- a/tests/Unit/APITest.php +++ b/tests/Unit/APITest.php @@ -20,10 +20,12 @@ use Piwik\Access; use Piwik\Config; use Piwik\Container\StaticContainer; +use Piwik\EventDispatcher; use Piwik\Http\BadRequestException; use Piwik\Log\LoggerInterface; use Piwik\Plugins\McpServer\API; use Piwik\Plugins\McpServer\McpServerFactory; +use Piwik\Plugins\McpServer\McpToolsProvider; use Piwik\Plugins\McpServer\Support\Access\McpAccessGate; use Piwik\Plugins\McpServer\Support\Access\McpUnavailableException; use Piwik\Plugins\McpServer\Support\Api\InternalApiAccessGuard; @@ -392,6 +394,8 @@ private function createRequest(?string $payload = null): ServerRequestInterface private function createFactory(): McpServerFactory { + $container = StaticContainer::getContainer(); + return new McpServerFactory( $this->createMock(LoggerInterface::class), new InMemorySessionStore(), @@ -399,8 +403,9 @@ private function createFactory(): McpServerFactory // happy-path tests need the real StaticContainer here. Tests that // never reach createServer() (guard rejections, auth failures) are // unaffected. - StaticContainer::getContainer(), + $container, new ToolCallParameterFormatter(), + new McpToolsProvider($container, StaticContainer::get(EventDispatcher::class)), ); } diff --git a/tests/Unit/McpServerFactoryTest.php b/tests/Unit/McpServerFactoryTest.php index 4fb0ce6e..61119d4e 100644 --- a/tests/Unit/McpServerFactoryTest.php +++ b/tests/Unit/McpServerFactoryTest.php @@ -19,6 +19,7 @@ use Piwik\Plugin\Manager; use Piwik\Plugins\McpServer\McpServerFactory; use Piwik\Plugins\McpServer\Support\Logging\ToolCallParameterFormatter; +use Piwik\Plugins\McpServer\tests\Framework\FixedMcpToolsProvider; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; use Psr\Container\ContainerInterface; @@ -55,8 +56,9 @@ public function testInitializeResponseHasExpectedServerInfoAndCapabilities(): vo new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $payload = McpTestHelper::makeInitializeRequest('init-1'); $response = McpTestHelper::postJson($server, $payload); @@ -93,8 +95,9 @@ public function testToolCallLoggingEnabledInjectsObservedHandler(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', [], 'missing-1'); @@ -129,8 +132,9 @@ public function testStringOneConfigEnablesFullParameterLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', ['query' => 'secret'], 'missing-full-1'); @@ -153,8 +157,9 @@ public function testBooleanTrueConfigEnablesToolCallLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', [], 'missing-bool-1'); @@ -177,8 +182,9 @@ public function testToolCallLoggingDisabledSkipsObservedHandlerInjection(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', [], 'missing-2'); @@ -201,8 +207,9 @@ public function testStringZeroConfigDisablesToolCallLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', [], 'missing-string-zero-1'); @@ -225,8 +232,9 @@ public function testStringTrueConfigDoesNotEnableToolCallLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', [], 'missing-string-true-1'); @@ -249,8 +257,9 @@ public function testToolCallLoggingMissingConfigSkipsObservedHandlerInjection(): new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', [], 'missing-3'); @@ -276,8 +285,9 @@ public function testConfiguredWarnLevelUsesWarning(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', [], 'missing-warn-1'); @@ -303,8 +313,9 @@ public function testConfiguredErrorLevelUsesError(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', [], 'missing-error-1'); @@ -330,8 +341,9 @@ public function testConfiguredInfoLevelUsesInfo(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', [], 'missing-info-1'); @@ -356,8 +368,9 @@ public function testConfiguredVerboseLevelUsesDebug(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', [], 'missing-verbose-1'); @@ -385,8 +398,9 @@ public function testInvalidToolCallLogLevelFallsBackToDebug(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider([]), ); - $server = $factory->createServer([]); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest('missing_tool', [], 'missing-invalid-level-1'); diff --git a/tests/Unit/McpToolsProviderTest.php b/tests/Unit/McpToolsProviderTest.php new file mode 100644 index 00000000..5f188bb9 --- /dev/null +++ b/tests/Unit/McpToolsProviderTest.php @@ -0,0 +1,234 @@ +makeBuiltinStubs(); + + $provider = new McpToolsProvider($this->makeContainer($builtins), $this->newEventDispatcher()); + + $tools = $provider->getAllTools(); + + self::assertCount(count($builtins), $tools); + self::assertSame(array_values($builtins), $tools); + } + + public function testAddToolsEventCanAppendPluginContributedTools(): void + { + $contributed = $this->createMock(McpTool::class); + + $eventDispatcher = $this->newEventDispatcher(); + $eventDispatcher->addObserver('McpServer.addTools', function (array &$tools) use ($contributed): void { + $tools[] = $contributed; + }); + + $provider = new McpToolsProvider($this->makeContainerWithStubs(), $eventDispatcher); + + $tools = $provider->getAllTools(); + + self::assertContains($contributed, $tools); + } + + public function testFilterToolsEventCanRemoveTools(): void + { + $eventDispatcher = $this->newEventDispatcher(); + $eventDispatcher->addObserver('McpServer.filterTools', static function (array &$tools): void { + $tools = []; + }); + + $provider = new McpToolsProvider($this->makeContainerWithStubs(), $eventDispatcher); + + $tools = $provider->getAllTools(); + + self::assertSame([], $tools); + } + + public function testFilterToolsRunsAfterAddTools(): void + { + $extra = $this->createMock(McpTool::class); + + $eventDispatcher = $this->newEventDispatcher(); + $eventDispatcher->addObserver('McpServer.addTools', function (array &$tools) use ($extra): void { + $tools[] = $extra; + }); + + $eventDispatcher->addObserver('McpServer.filterTools', static function (array &$tools) use ($extra): void { + $tools = array_values(array_filter( + $tools, + static fn(McpTool $tool): bool => $tool === $extra, + )); + }); + + $provider = new McpToolsProvider($this->makeContainerWithStubs(), $eventDispatcher); + + $tools = $provider->getAllTools(); + + self::assertSame([$extra], $tools); + } + + public function testRejectsNonMcpToolEntryAddedThroughEvent(): void + { + $eventDispatcher = $this->newEventDispatcher(); + $eventDispatcher->addObserver('McpServer.addTools', static function (array &$tools): void { + $tools[] = new \stdClass(); + }); + + $provider = new McpToolsProvider($this->makeContainerWithStubs(), $eventDispatcher); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('McpServer tool list contained a non-McpTool entry: stdClass.'); + + $provider->getAllTools(); + } + + public function testRejectsNonArrayToolListReassignedThroughFilterToolsEvent(): void + { + $eventDispatcher = $this->newEventDispatcher(); + $eventDispatcher->addObserver('McpServer.filterTools', static function (&$tools): void { + $tools = 'not-an-array'; + }); + + $provider = new McpToolsProvider($this->makeContainerWithStubs(), $eventDispatcher); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('McpServer tool list must be an array, got string.'); + + $provider->getAllTools(); + } + + public function testRejectsNonArrayToolListReassignedThroughAddToolsEvent(): void + { + $eventDispatcher = $this->newEventDispatcher(); + $eventDispatcher->addObserver('McpServer.addTools', static function (&$tools): void { + $tools = 'not-an-array'; + }); + + $provider = new McpToolsProvider($this->makeContainerWithStubs(), $eventDispatcher); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('McpServer tool list must be an array, got string.'); + + $provider->getAllTools(); + } + + public function testRejectsBuiltinClassThatDoesNotResolveToMcpTool(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn(new \stdClass()); + + $provider = new McpToolsProvider($container, $this->newEventDispatcher()); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('did not resolve to an McpTool instance'); + + $provider->getAllTools(); + } + + /** + * A dispatcher backed by a Plugin\Manager that reports no plugins runs + * entirely in-memory: only the observers a test registers itself fire, so + * no Matomo environment is needed. + */ + private function newEventDispatcher(): EventDispatcher + { + $pluginManager = $this->createMock(PluginManager::class); + $pluginManager->method('getPluginsLoadedAndActivated')->willReturn([]); + + return new EventDispatcher($pluginManager); + } + + private function makeContainerWithStubs(): ContainerInterface + { + return $this->makeContainer($this->makeBuiltinStubs()); + } + + /** + * Stub instances keyed by built-in tool class-string, one per class the + * provider resolves from the container. + * + * @return array + */ + private function makeBuiltinStubs(): array + { + return [ + ApiCallCreate::class => $this->createMock(McpTool::class), + ApiCallDelete::class => $this->createMock(McpTool::class), + ApiCallFull::class => $this->createMock(McpTool::class), + ApiCallRead::class => $this->createMock(McpTool::class), + ApiCallUpdate::class => $this->createMock(McpTool::class), + ApiGet::class => $this->createMock(McpTool::class), + ApiList::class => $this->createMock(McpTool::class), + DimensionGet::class => $this->createMock(McpTool::class), + DimensionList::class => $this->createMock(McpTool::class), + GoalGet::class => $this->createMock(McpTool::class), + GoalList::class => $this->createMock(McpTool::class), + ReportList::class => $this->createMock(McpTool::class), + ReportMetadata::class => $this->createMock(McpTool::class), + ReportProcessed::class => $this->createMock(McpTool::class), + SegmentGet::class => $this->createMock(McpTool::class), + SegmentList::class => $this->createMock(McpTool::class), + SiteGet::class => $this->createMock(McpTool::class), + SiteList::class => $this->createMock(McpTool::class), + SiteSearch::class => $this->createMock(McpTool::class), + ]; + } + + /** + * @param array $tools + */ + private function makeContainer(array $tools): ContainerInterface + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback(static function (string $id) use ($tools): McpTool { + if (!array_key_exists($id, $tools)) { + throw new \LogicException("Unexpected container lookup for {$id}"); + } + + return $tools[$id]; + }); + + return $container; + } +} diff --git a/tests/Unit/RawApiToolVisibilityTest.php b/tests/Unit/RawApiToolVisibilityTest.php index 66d037c3..ec3a0f3d 100644 --- a/tests/Unit/RawApiToolVisibilityTest.php +++ b/tests/Unit/RawApiToolVisibilityTest.php @@ -29,6 +29,7 @@ use Piwik\Plugins\McpServer\Support\Logging\ToolCallParameterFormatter; use Piwik\Plugins\McpServer\Support\Pagination\CursorPaginator; use Piwik\Plugins\McpServer\Support\Tooling\PaginatedCollectionResponder; +use Piwik\Plugins\McpServer\tests\Framework\FixedMcpToolsProvider; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; use Piwik\Plugins\McpServer\tests\Framework\StatefulSystemSettingsStub; use Psr\Container\ContainerInterface; @@ -154,8 +155,9 @@ private function listToolsByNameForMode(string $rawApiAccessMode): array new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + new FixedMcpToolsProvider($this->buildRawApiTools($systemSettings)), ); - $server = $factory->createServer($this->buildRawApiTools($systemSettings)); + $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeListToolsRequest('list-tools-1');