From efdebf21a2a0edd92b98db70f7ef60137a74e832 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Wed, 4 Mar 2026 21:29:31 +0100 Subject: [PATCH 01/15] McpTool: matomo_api_list --- .../ApiMethodSummaryQueryServiceInterface.php | 23 ++ .../Api/ApiMethodSummaryQueryRecord.php | 31 ++ .../Records/Api/ApiMethodSummaryRecord.php | 53 +++ McpServerFactory.php | 34 ++ McpTools/ApiList.php | 82 +++++ Schemas/Api/ApiListToolInputSchema.php | 55 +++ .../Api/ApiMethodSummaryToolOutputSchema.php | 59 +++ Services/Api/ApiMethodSummaryQueryService.php | 229 ++++++++++++ Support/Access/RawApiAccessMode.php | 64 ++++ Support/Pagination/ApiMethodsPagination.php | 52 +++ config/config.php | 5 + docs/faq.md | 12 + tests/Integration/McpTools/ApiListTest.php | 273 ++++++++++++++ .../McpToolsContractBaselineTest.php | 20 + tests/Integration/McpToolsContractTest.php | 61 ++++ tests/Unit/McpServerFactoryTest.php | 44 +++ tests/Unit/McpTools/ApiListTest.php | 343 ++++++++++++++++++ .../Api/ApiMethodSummaryQueryServiceTest.php | 196 ++++++++++ .../Support/Access/RawApiAccessModeTest.php | 46 +++ 19 files changed, 1682 insertions(+) create mode 100644 Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php create mode 100644 Contracts/Records/Api/ApiMethodSummaryQueryRecord.php create mode 100644 Contracts/Records/Api/ApiMethodSummaryRecord.php create mode 100644 McpTools/ApiList.php create mode 100644 Schemas/Api/ApiListToolInputSchema.php create mode 100644 Schemas/Api/ApiMethodSummaryToolOutputSchema.php create mode 100644 Services/Api/ApiMethodSummaryQueryService.php create mode 100644 Support/Access/RawApiAccessMode.php create mode 100644 Support/Pagination/ApiMethodsPagination.php create mode 100644 tests/Integration/McpTools/ApiListTest.php create mode 100644 tests/Unit/McpTools/ApiListTest.php create mode 100644 tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php create mode 100644 tests/Unit/Support/Access/RawApiAccessModeTest.php diff --git a/Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php b/Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php new file mode 100644 index 0000000..928692f --- /dev/null +++ b/Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php @@ -0,0 +1,23 @@ + + */ + public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array; +} diff --git a/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php b/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php new file mode 100644 index 0000000..682e848 --- /dev/null +++ b/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php @@ -0,0 +1,31 @@ +, + * } + */ +final class ApiMethodSummaryRecord +{ + /** @param list $parameters */ + public function __construct( + public readonly string $module, + public readonly string $action, + public readonly string $method, + public readonly array $parameters, + ) { + } + + /** + * @return ApiMethodSummaryArray + */ + public function toArray(): array + { + return [ + 'module' => $this->module, + 'action' => $this->action, + 'method' => $this->method, + 'parameters' => $this->parameters, + ]; + } +} diff --git a/McpServerFactory.php b/McpServerFactory.php index bb49ac5..525c7cf 100644 --- a/McpServerFactory.php +++ b/McpServerFactory.php @@ -14,13 +14,18 @@ use Matomo\Dependencies\McpServer\Mcp\Capability\Registry; use Matomo\Dependencies\McpServer\Mcp\Capability\Registry\ReferenceHandler; use Matomo\Dependencies\McpServer\Mcp\Schema\ServerCapabilities; +use Matomo\Dependencies\McpServer\Mcp\Schema\ToolAnnotations; use Matomo\Dependencies\McpServer\Mcp\Server; use Matomo\Dependencies\McpServer\Mcp\Server\Handler\Request\CallToolHandler; use Matomo\Dependencies\McpServer\Mcp\Server\Session\SessionStoreInterface; use Piwik\Config; use Piwik\Log\LoggerInterface; use Piwik\Plugin\Manager; +use Piwik\Plugins\McpServer\McpTools\ApiList; +use Piwik\Plugins\McpServer\Schemas\Api\ApiListToolInputSchema; +use Piwik\Plugins\McpServer\Schemas\Api\ApiMethodSummaryToolOutputSchema; use Piwik\Plugins\McpServer\Server\Handler\Request\ObservedCallToolHandler; +use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\Support\Logging\ToolCallParameterFormatter; use Psr\Container\ContainerInterface; use Psr\Log\NullLogger; @@ -66,6 +71,29 @@ public function createServer(): Server completions: false, )); + $rawApiAccessMode = $this->resolveRawApiAccessMode(); + if (RawApiAccessMode::allowsToolRegistration($rawApiAccessMode)) { + // This tool is registered manually (not via attribute discovery) + // so registration can be gated by the raw API access mode. + $builder->addTool( + [ApiList::class, 'list'], + ApiList::TOOL_NAME, + "Use when: you need discoverable Matomo API methods and parameter metadata.\n" + . "Purpose: return paginated API method summaries aligned with Matomo API docs visibility.\n" + . "Next: choose a method and map parameters for subsequent raw API tooling.", + new ToolAnnotations( + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + ), + ApiListToolInputSchema::SCHEMA, + null, + null, + ApiMethodSummaryToolOutputSchema::PAGINATED_LIST, + ); + } + if ($loggingConfig['logToolCalls']) { $referenceHandler = new ReferenceHandler($this->container); $callToolHandler = new CallToolHandler($registry, $referenceHandler, new NullLogger()); @@ -128,6 +156,12 @@ private function resolveToolCallLogLevel(array $config): string return $normalizedLevel; } + private function resolveRawApiAccessMode(): string + { + $config = $this->getMcpServerConfig(); + return RawApiAccessMode::normalize($config['raw_api_access_mode'] ?? null); + } + /** * @return array */ diff --git a/McpTools/ApiList.php b/McpTools/ApiList.php new file mode 100644 index 0000000..4b48908 --- /dev/null +++ b/McpTools/ApiList.php @@ -0,0 +1,82 @@ +, + * next_cursor: string|null, + * has_more: bool, + * total_rows: int, + * } + */ + public function list( + ?int $limit = null, + ?string $cursor = null, + ?string $sort = null, + ?string $module = null, + ?string $search = null, + ): array { + $query = ApiMethodSummaryQueryRecord::fromInputs( + RawApiAccessMode::normalize(Config::getInstance()->McpServer['raw_api_access_mode'] ?? null), + $module, + $search, + ); + + $cursorContext = CursorContextBuilder::forTool(self::TOOL_NAME, [ + 'module' => $query->module, + 'search' => $query->search, + 'mode' => $query->accessMode, + ]); + + $response = $this->paginationResponder->paginateRecords( + $this->queryService->getApiMethodSummaries($query), + static fn(ApiMethodSummaryRecord $record): array => $record->toArray(), + 'methods', + ApiMethodsPagination::createConfig(), + ApiMethodsPagination::SORT_MODULE_ASC, + $limit, + $cursor, + $sort, + $cursorContext, + static fn(ApiMethodSummaryRecord $record): array => [ + 'module' => $record->module, + 'method' => $record->method, + ] + ); + + /** @var array{methods: list, next_cursor: string|null, has_more: bool, total_rows: int} $response */ + return $response; + } +} diff --git a/Schemas/Api/ApiListToolInputSchema.php b/Schemas/Api/ApiListToolInputSchema.php new file mode 100644 index 0000000..e2b5313 --- /dev/null +++ b/Schemas/Api/ApiListToolInputSchema.php @@ -0,0 +1,55 @@ + 'object', + 'properties' => [ + 'limit' => [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => ApiMethodsPagination::LIMIT_MAX, + 'description' => 'Maximum number of results to return. Uses schema constraints.', + ], + 'cursor' => [ + 'type' => 'string', + 'description' => 'Opaque cursor for pagination.', + ], + 'sort' => [ + 'type' => 'string', + 'enum' => [ + ApiMethodsPagination::SORT_MODULE_ASC, + ApiMethodsPagination::SORT_MODULE_DESC, + ApiMethodsPagination::SORT_METHOD_ASC, + ApiMethodsPagination::SORT_METHOD_DESC, + ], + 'description' => 'Sort order for results.', + ], + 'module' => [ + 'type' => 'string', + 'minLength' => 1, + 'description' => 'Optional exact module-name filter (case-insensitive).', + ], + 'search' => [ + 'type' => 'string', + 'minLength' => 1, + 'description' => 'Optional case-insensitive substring filter on the ' + . 'composite Module.action method name.', + ], + ], + 'additionalProperties' => false, + ]; +} diff --git a/Schemas/Api/ApiMethodSummaryToolOutputSchema.php b/Schemas/Api/ApiMethodSummaryToolOutputSchema.php new file mode 100644 index 0000000..00c5700 --- /dev/null +++ b/Schemas/Api/ApiMethodSummaryToolOutputSchema.php @@ -0,0 +1,59 @@ + 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'type' => ['type' => ['string', 'null']], + 'required' => ['type' => 'boolean'], + 'allowsNull' => ['type' => 'boolean'], + 'hasDefault' => ['type' => 'boolean'], + 'defaultValue' => [], + ], + 'required' => ['name', 'type', 'required', 'allowsNull', 'hasDefault', 'defaultValue'], + 'additionalProperties' => false, + ]; + + public const ITEM = [ + 'type' => 'object', + 'properties' => [ + 'module' => ['type' => 'string'], + 'action' => ['type' => 'string'], + 'method' => ['type' => 'string'], + 'parameters' => [ + 'type' => 'array', + 'items' => self::PARAMETER, + ], + ], + 'required' => ['module', 'action', 'method', 'parameters'], + 'additionalProperties' => false, + ]; + + public const PAGINATED_LIST = [ + 'type' => 'object', + 'properties' => [ + 'methods' => [ + 'type' => 'array', + 'items' => self::ITEM, + ], + 'next_cursor' => ['type' => ['string', 'null']], + 'has_more' => ['type' => 'boolean'], + 'total_rows' => ['type' => 'integer'], + ], + 'required' => ['methods', 'next_cursor', 'has_more', 'total_rows'], + 'additionalProperties' => false, + ]; +} diff --git a/Services/Api/ApiMethodSummaryQueryService.php b/Services/Api/ApiMethodSummaryQueryService.php new file mode 100644 index 0000000..b078716 --- /dev/null +++ b/Services/Api/ApiMethodSummaryQueryService.php @@ -0,0 +1,229 @@ + + */ + public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array + { + return $this->filterRecords($this->loadApiMethodSummaries(), $query); + } + + /** + * Public for testability and to share normalization contract across MCP tools. + * + * @return array + */ + public function loadApiMethodSummaries(): array + { + // Mirrors API docs loading semantics by forcing API class registration through DocumentationGenerator. + new DocumentationGenerator(); + + $proxy = Proxy::getInstance(); + $metadata = $proxy->getMetadata(); + + $records = []; + foreach ($metadata as $className => $classInfo) { + if (!is_array($classInfo)) { + continue; + } + + $module = $proxy->getModuleNameFromClassName((string) $className); + foreach ($classInfo as $action => $methodInfo) { + $isDeprecated = $proxy->isDeprecatedMethod((string) $className, (string) $action); + $shouldInclude = $this->shouldIncludeMethodMetadataEntry( + $action, + $methodInfo, + $isDeprecated, + ); + if (!$shouldInclude) { + continue; + } + /** @var array $methodInfo */ + + $parameters = $this->normalizeParameterMetadata($methodInfo['parameters'] ?? null); + + $records[] = new ApiMethodSummaryRecord( + module: $module, + action: (string) $action, + method: $module . '.' . $action, + parameters: $parameters, + ); + } + } + + return $records; + } + + /** + * Public for testability and to share normalization contract across MCP tools. + * + * @param array $records + * @return array + */ + public function filterRecords(array $records, ApiMethodSummaryQueryRecord $query): array + { + $records = $this->filterByAccessMode($records, $query->accessMode); + $records = $this->filterByModule($records, $query->module); + $records = $this->filterBySearch($records, $query->search); + + return $records; + } + + /** + * Public for testability and to share normalization contract across MCP tools. + * + * @return list + */ + public function normalizeParameterMetadata(mixed $rawParameters): array + { + if (!is_array($rawParameters)) { + return []; + } + + $normalized = []; + foreach ($rawParameters as $name => $parameterInfo) { + if (!is_string($name) || !is_array($parameterInfo)) { + continue; + } + + $hasDefault = array_key_exists('default', $parameterInfo); + $defaultValue = null; + if ($hasDefault) { + $defaultValue = $parameterInfo['default']; + if ($defaultValue instanceof NoDefaultValue) { + $hasDefault = false; + $defaultValue = null; + } + } + + $allowsNull = $parameterInfo['allowsNull'] ?? false; + $type = $parameterInfo['type'] ?? null; + + $normalized[] = [ + 'name' => $name, + 'type' => is_string($type) ? $type : null, + 'required' => !$hasDefault, + 'allowsNull' => (bool) $allowsNull, + 'hasDefault' => $hasDefault, + 'defaultValue' => $this->normalizeDefaultParameterValue($defaultValue), + ]; + } + + return $normalized; + } + + /** + * Public for testability and to share normalization contract across MCP tools. + */ + public function normalizeDefaultParameterValue(mixed $value): mixed + { + if (is_scalar($value) || $value === null || is_array($value)) { + return $value; + } + + try { + $encoded = json_encode($value, JSON_THROW_ON_ERROR); + return json_decode($encoded, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable) { + return null; + } + } + + /** + * Public for testability and to share normalization contract across MCP tools. + */ + public function shouldIncludeMethodMetadataEntry( + mixed $action, + mixed $methodInfo, + bool $isDeprecated, + ): bool { + if (!is_string($action) || $action === '__documentation') { + return false; + } + + if ($isDeprecated) { + return false; + } + + return is_array($methodInfo); + } + + /** + * @param array $records + * @return array + */ + private function filterByAccessMode(array $records, string $accessMode): array + { + if ($accessMode === RawApiAccessMode::FULL) { + return $records; + } + + return array_values(array_filter( + $records, + static fn(ApiMethodSummaryRecord $record): bool => RawApiAccessMode::allowsMethodAction( + $accessMode, + $record->action, + ) + )); + } + + /** + * @param array $records + * @return array + */ + private function filterByModule(array $records, string $moduleFilter): array + { + if ($moduleFilter === '') { + return $records; + } + + return array_values(array_filter( + $records, + static fn(ApiMethodSummaryRecord $record): bool => strtolower($record->module) === $moduleFilter + )); + } + + /** + * @param array $records + * @return array + */ + private function filterBySearch(array $records, string $searchTerm): array + { + if ($searchTerm === '') { + return $records; + } + + return array_values(array_filter( + $records, + static fn(ApiMethodSummaryRecord $record): bool => str_contains(strtolower($record->method), $searchTerm) + )); + } +} diff --git a/Support/Access/RawApiAccessMode.php b/Support/Access/RawApiAccessMode.php new file mode 100644 index 0000000..b60dcba --- /dev/null +++ b/Support/Access/RawApiAccessMode.php @@ -0,0 +1,64 @@ + DI::autowire(ApiMethodSummaryQueryService::class), CoreApiModuleGatewayInterface::class => DI::autowire(CoreApiModuleGateway::class), CoreCustomDimensionsGatewayInterface::class => DI::autowire(CoreCustomDimensionsGateway::class), CoreGoalsGatewayInterface::class => DI::autowire(CoreGoalsGateway::class), @@ -77,5 +81,6 @@ DbSessionStore::class => DI::autowire(), McpServerFactory::class => DI::autowire(), PaginatedCollectionResponder::class => DI::autowire(), + ApiList::class => DI::autowire(), Tasks::class => DI::autowire(), ]; diff --git a/docs/faq.md b/docs/faq.md index a6b5c94..7ca045b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -27,6 +27,18 @@ log_tool_call_parameters_full = 0 - `log_tool_call_level`: Tool-call logging level when `log_tool_calls = 1`. Accepted values: `ERROR`, `WARN`/`WARNING`, `INFO`, `DEBUG`, `VERBOSE` (case-insensitive). Missing or invalid values default to `DEBUG`. `VERBOSE` is logged via debug-level logger calls. - `log_tool_call_parameters_full`: Logs full tool-call parameter values when set to `1`. Default is redacted parameter logging when set to `0` (may expose sensitive input data when enabled). +Configure raw Matomo API tool access in `config/config.ini.php`: + +```ini +[McpServer] +raw_api_access_mode = none +``` + +- `raw_api_access_mode`: Controls raw API discovery tool visibility for `matomo_api_list`. +- `none`: hides `matomo_api_list` (default). +- `read`: shows `matomo_api_list` and currently returns only API actions with `get`/`is` prefix. This prefix-based filter is a temporary heuristic and may be replaced by a more accurate read/write classification in the future. +- `full`: shows `matomo_api_list` and returns all discoverable API actions. + ## Enabling MCP MCP access is disabled by default and must be enabled in **Administration -> System -> Plugin Settings -> McpServer**. diff --git a/tests/Integration/McpTools/ApiListTest.php b/tests/Integration/McpTools/ApiListTest.php new file mode 100644 index 0000000..04aea76 --- /dev/null +++ b/tests/Integration/McpTools/ApiListTest.php @@ -0,0 +1,273 @@ +McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['limit' => 500], + ); + + self::assertArrayHasKey('methods', $content); + self::assertIsArray($content['methods']); + self::assertNotEmpty($content['methods']); + + foreach ($content['methods'] as $method) { + self::assertIsArray($method); + self::assertArrayHasKey('action', $method); + self::assertIsString($method['action']); + $normalizedAction = strtolower($method['action']); + self::assertTrue( + str_starts_with($normalizedAction, 'get') || str_starts_with($normalizedAction, 'is'), + 'Read mode returned non-read action: ' . $method['action'], + ); + } + } + + public function testFullModeCanReturnMutatingActions(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['search' => 'add', 'limit' => 500], + ); + + self::assertArrayHasKey('methods', $content); + self::assertIsArray($content['methods']); + self::assertNotEmpty($content['methods']); + + $foundMutatingAction = false; + foreach ($content['methods'] as $method) { + if (!is_array($method) || !is_string($method['action'] ?? null)) { + continue; + } + + $normalizedAction = strtolower($method['action']); + if (!str_starts_with($normalizedAction, 'get') && !str_starts_with($normalizedAction, 'is')) { + $foundMutatingAction = true; + break; + } + } + + self::assertTrue($foundMutatingAction, 'Expected at least one non-read action in full mode.'); + } + + public function testReturnsPagedResultsWithCursor(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + + $firstPage = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['limit' => 2, 'sort' => ApiMethodsPagination::SORT_METHOD_ASC], + __METHOD__ . '#1', + ); + + self::assertIsArray($firstPage['methods'] ?? null); + self::assertCount(2, $firstPage['methods']); + self::assertTrue($firstPage['has_more']); + self::assertIsString($firstPage['next_cursor']); + self::assertGreaterThanOrEqual(3, $firstPage['total_rows'] ?? 0); + + $secondPage = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + [ + 'limit' => 2, + 'sort' => ApiMethodsPagination::SORT_METHOD_ASC, + 'cursor' => $firstPage['next_cursor'], + ], + __METHOD__ . '#2', + ); + + self::assertIsArray($secondPage['methods'] ?? null); + self::assertNotEmpty($secondPage['methods']); + self::assertSame($firstPage['total_rows'] ?? null, $secondPage['total_rows'] ?? null); + + $firstPageMethods = array_map( + static fn(array $row): string => (string) ($row['method'] ?? ''), + $firstPage['methods'], + ); + $secondPageMethods = array_map( + static fn(array $row): string => (string) ($row['method'] ?? ''), + $secondPage['methods'], + ); + self::assertSame([], array_values(array_intersect($firstPageMethods, $secondPageMethods))); + } + + public function testRejectsInvalidLimit(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $message = McpTestHelper::callToolExpectInvalidParams( + $server, + $sessionId, + 'matomo_api_list', + ['limit' => 0], + __METHOD__, + ); + + self::assertStringContainsString("Invalid parameters for tool 'matomo_api_list':", $message->message ?? ''); + self::assertStringContainsString('limit', $message->message ?? ''); + } + + public function testRejectsInvalidSort(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $message = McpTestHelper::callToolExpectInvalidParams( + $server, + $sessionId, + 'matomo_api_list', + ['sort' => 'invalid'], + __METHOD__, + ); + + self::assertStringContainsString("Invalid parameters for tool 'matomo_api_list':", $message->message ?? ''); + self::assertStringContainsString('sort', $message->message ?? ''); + } + + public function testRejectsInvalidCursor(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + 'matomo_api_list', + ['cursor' => 'invalid'], + 'Invalid cursor.', + __METHOD__, + ); + } + + public function testRejectsCursorSortMismatch(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + + $firstPage = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['limit' => 1, 'sort' => ApiMethodsPagination::SORT_METHOD_DESC], + __METHOD__ . '#1', + ); + $nextCursor = $firstPage['next_cursor'] ?? null; + self::assertIsString($nextCursor); + + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + 'matomo_api_list', + ['cursor' => $nextCursor, 'sort' => ApiMethodsPagination::SORT_METHOD_ASC], + 'Invalid cursor.', + __METHOD__ . '#2', + ); + } + + public function testRejectsCursorFromDifferentFilterContext(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + + $firstPage = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['limit' => 1, 'sort' => ApiMethodsPagination::SORT_METHOD_ASC, 'search' => 'get'], + __METHOD__ . '#1', + ); + $nextCursor = $firstPage['next_cursor'] ?? null; + self::assertIsString($nextCursor); + + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + 'matomo_api_list', + ['cursor' => $nextCursor, 'sort' => ApiMethodsPagination::SORT_METHOD_ASC, 'search' => 'add'], + 'Invalid cursor.', + __METHOD__ . '#2', + ); + } + + public function testNoneModeHidesAndRejectsToolCall(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; + self::assertNotContains('matomo_api_list', $this->listToolNamesForCurrentConfig()); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeCallToolRequest('matomo_api_list', [], __METHOD__); + $response = McpTestHelper::postJson($server, $payload, ['Mcp-Session-Id' => $sessionId]); + $message = McpTestHelper::decodeError($response); + + self::assertSame(JsonRpcError::METHOD_NOT_FOUND, $message->code); + } + + /** + * @return list + */ + private function listToolNamesForCurrentConfig(): array + { + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeListToolsRequest(__METHOD__); + + $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) => $tool->name, $result->tools)); + } +} diff --git a/tests/Integration/McpToolsContractBaselineTest.php b/tests/Integration/McpToolsContractBaselineTest.php index ab352fb..3cd5b79 100644 --- a/tests/Integration/McpToolsContractBaselineTest.php +++ b/tests/Integration/McpToolsContractBaselineTest.php @@ -12,6 +12,7 @@ namespace Piwik\Plugins\McpServer\tests\Integration; use Matomo\Dependencies\McpServer\Mcp\Server; +use Piwik\Config; use Piwik\Plugins\API\API as ApiModuleApi; use Piwik\Plugins\CustomDimensions\API as CustomDimensionsApi; use Piwik\Plugins\Goals\API as GoalsApi; @@ -27,6 +28,7 @@ use Piwik\Plugins\McpServer\McpTools\SiteGet; use Piwik\Plugins\McpServer\McpTools\SiteList; use Piwik\Plugins\McpServer\McpTools\SiteSearch; +use Piwik\Plugins\McpServer\Schemas\Api\ApiMethodSummaryToolOutputSchema; use Piwik\Plugins\McpServer\Schemas\Dimensions\DimensionDetailToolOutputSchema; use Piwik\Plugins\McpServer\Schemas\Dimensions\DimensionSummaryToolOutputSchema; use Piwik\Plugins\McpServer\Schemas\Goals\GoalDetailToolOutputSchema; @@ -235,6 +237,24 @@ public function testReportListSerializesEmptyParametersAsObjectInBaselineRespons self::assertStringContainsString('"parameters":{}', $body); } + public function testApiListSuccessShapeInReadMode(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['limit' => 5], + __METHOD__, + ); + + ContractShapeAssert::assertMatchesSchema(ApiMethodSummaryToolOutputSchema::PAGINATED_LIST, $content); + self::assertNotEmpty($content['methods'] ?? []); + } + /** * @return array}> */ diff --git a/tests/Integration/McpToolsContractTest.php b/tests/Integration/McpToolsContractTest.php index 5a29088..1972c75 100644 --- a/tests/Integration/McpToolsContractTest.php +++ b/tests/Integration/McpToolsContractTest.php @@ -11,6 +11,8 @@ namespace Piwik\Plugins\McpServer\tests\Integration; +use Matomo\Dependencies\McpServer\Mcp\Schema\Tool; +use Piwik\Config; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -22,6 +24,8 @@ class McpToolsContractTest extends IntegrationTestCase { public function testToolsListContainsAllPluginTools(): void { + Config::getInstance()->McpServer = []; + $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeListToolsRequest('list-1'); @@ -138,4 +142,61 @@ public function testToolsListContainsAllPluginTools(): void self::assertSame($expectedHints['openWorldHint'], $tool->annotations->openWorldHint); } } + + public function testRawApiListToolIsHiddenWhenRawAccessModeIsNone(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; + $toolsByName = $this->listToolsByNameForCurrentConfig(); + + self::assertArrayNotHasKey('matomo_api_list', $toolsByName); + } + + public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessModeIsRead(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + $toolsByName = $this->listToolsByNameForCurrentConfig(); + + self::assertArrayHasKey('matomo_api_list', $toolsByName); + $tool = $toolsByName['matomo_api_list']; + self::assertNotNull($tool->annotations); + self::assertTrue($tool->annotations->readOnlyHint); + self::assertFalse($tool->annotations->destructiveHint); + self::assertTrue($tool->annotations->idempotentHint); + self::assertFalse($tool->annotations->openWorldHint); + } + + public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessModeIsFull(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + $toolsByName = $this->listToolsByNameForCurrentConfig(); + + self::assertArrayHasKey('matomo_api_list', $toolsByName); + $tool = $toolsByName['matomo_api_list']; + self::assertNotNull($tool->annotations); + self::assertTrue($tool->annotations->readOnlyHint); + self::assertFalse($tool->annotations->destructiveHint); + self::assertTrue($tool->annotations->idempotentHint); + self::assertFalse($tool->annotations->openWorldHint); + } + + /** + * @return array + */ + private function listToolsByNameForCurrentConfig(): array + { + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeListToolsRequest(__METHOD__); + + $response = McpTestHelper::postJson($server, $payload, ['Mcp-Session-Id' => $sessionId]); + $message = McpTestHelper::decodeResponse($response); + $result = McpTestHelper::parseListTools($message); + + $toolsByName = []; + foreach ($result->tools as $tool) { + $toolsByName[$tool->name] = $tool; + } + + return $toolsByName; + } } diff --git a/tests/Unit/McpServerFactoryTest.php b/tests/Unit/McpServerFactoryTest.php index d40f9c6..57f9126 100644 --- a/tests/Unit/McpServerFactoryTest.php +++ b/tests/Unit/McpServerFactoryTest.php @@ -395,4 +395,48 @@ public function testInvalidToolCallLogLevelFallsBackToDebug(): void self::assertSame(JsonRpcError::METHOD_NOT_FOUND, $message->code); } + + public function testRawApiListToolIsHiddenWhenRawAccessModeIsMissingOrNone(): void + { + Config::getInstance()->McpServer = []; + $toolsWhenMissing = $this->listToolNamesForCurrentConfig(); + self::assertNotContains('matomo_api_list', $toolsWhenMissing); + + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; + $toolsWhenNone = $this->listToolNamesForCurrentConfig(); + self::assertNotContains('matomo_api_list', $toolsWhenNone); + } + + public function testRawApiListToolIsVisibleWhenRawAccessModeIsReadOrFull(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + $toolsWhenRead = $this->listToolNamesForCurrentConfig(); + self::assertContains('matomo_api_list', $toolsWhenRead); + + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + $toolsWhenFull = $this->listToolNamesForCurrentConfig(); + self::assertContains('matomo_api_list', $toolsWhenFull); + } + + /** + * @return list + */ + private function listToolNamesForCurrentConfig(): array + { + $factory = new McpServerFactory( + $this->createMock(LoggerInterface::class), + new InMemorySessionStore(), + $this->createMock(ContainerInterface::class), + new ToolCallParameterFormatter(), + ); + $server = $factory->createServer(); + $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) => $tool->name, $result->tools)); + } } diff --git a/tests/Unit/McpTools/ApiListTest.php b/tests/Unit/McpTools/ApiListTest.php new file mode 100644 index 0000000..98e910c --- /dev/null +++ b/tests/Unit/McpTools/ApiListTest.php @@ -0,0 +1,343 @@ +|null */ + private ?array $originalMcpServerConfig = null; + + public function setUp(): void + { + parent::setUp(); + + $originalConfig = Config::getInstance()->McpServer ?? null; + $this->originalMcpServerConfig = is_array($originalConfig) ? $originalConfig : null; + } + + public function tearDown(): void + { + Config::getInstance()->McpServer = $this->originalMcpServerConfig; + + parent::tearDown(); + } + + public function testListReturnsReadOnlyMethodsInReadMode(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $tool = new ApiList( + $this->createQueryServiceStub( + static fn(ApiMethodSummaryQueryRecord $query): array => [ + new ApiMethodSummaryRecord('UsersManager', 'getUsers', 'UsersManager.getUsers', []), + ] + ), + new PaginatedCollectionResponder(new CursorPaginator()), + ); + + $actual = $tool->list(limit: 10, sort: ApiMethodsPagination::SORT_METHOD_ASC); + + self::assertSame([ + 'methods' => [ + [ + 'module' => 'UsersManager', + 'action' => 'getUsers', + 'method' => 'UsersManager.getUsers', + 'parameters' => [], + ], + ], + 'next_cursor' => null, + 'has_more' => false, + 'total_rows' => 1, + ], $actual); + } + + public function testListReturnsAllMethodsInFullModeAndSupportsFilters(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + $capturedQuery = null; + + $tool = new ApiList( + $this->createQueryServiceStub( + static function (ApiMethodSummaryQueryRecord $query) use (&$capturedQuery): array { + $capturedQuery = $query; + + return [ + new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), + ]; + }, + ), + new PaginatedCollectionResponder(new CursorPaginator()), + ); + + $actual = $tool->list(module: 'usersmanager', search: 'add', limit: 10); + + self::assertSame([ + 'methods' => [ + [ + 'module' => 'UsersManager', + 'action' => 'addUser', + 'method' => 'UsersManager.addUser', + 'parameters' => [], + ], + ], + 'next_cursor' => null, + 'has_more' => false, + 'total_rows' => 1, + ], $actual); + self::assertInstanceOf(ApiMethodSummaryQueryRecord::class, $capturedQuery); + self::assertSame('full', $capturedQuery->accessMode); + self::assertSame('usersmanager', $capturedQuery->module); + self::assertSame('add', $capturedQuery->search); + } + + public function testListRejectsInvalidCursor(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $tool = new ApiList( + $this->createQueryServiceStub(static fn(ApiMethodSummaryQueryRecord $query): array => []), + new PaginatedCollectionResponder(new CursorPaginator()), + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Invalid cursor.'); + + $tool->list(cursor: 'invalid'); + } + + public function testListSupportsPaginationAndSortOrdering(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + ); + + $firstPage = $tool->list(limit: 2, sort: ApiMethodsPagination::SORT_METHOD_ASC); + self::assertCount(2, $firstPage['methods']); + self::assertTrue($firstPage['has_more']); + self::assertIsString($firstPage['next_cursor']); + self::assertSame(5, $firstPage['total_rows']); + + $secondPage = $tool->list( + limit: 2, + cursor: $firstPage['next_cursor'], + sort: ApiMethodsPagination::SORT_METHOD_ASC, + ); + self::assertCount(2, $secondPage['methods']); + self::assertTrue($secondPage['has_more']); + self::assertIsString($secondPage['next_cursor']); + self::assertSame(5, $secondPage['total_rows']); + + $firstPageMethods = array_map( + static fn(array $row): string => $row['method'], + $firstPage['methods'], + ); + $secondPageMethods = array_map( + static fn(array $row): string => $row['method'], + $secondPage['methods'], + ); + self::assertSame([], array_values(array_intersect($firstPageMethods, $secondPageMethods))); + + $descPage = $tool->list(limit: 5, sort: ApiMethodsPagination::SORT_METHOD_DESC); + $descMethods = array_map( + static fn(array $row): string => $row['method'], + $descPage['methods'], + ); + $expectedDesc = $descMethods; + rsort($expectedDesc); + self::assertSame($expectedDesc, $descMethods); + } + + public function testListRejectsCursorWhenModeChanges(): void + { + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + ); + + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC); + $cursor = $firstPage['next_cursor'] ?? null; + self::assertIsString($cursor); + + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Invalid cursor.'); + $tool->list(limit: 1, cursor: $cursor, sort: ApiMethodsPagination::SORT_METHOD_ASC); + } + + public function testListRejectsCursorWhenSearchChanges(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + ); + + $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC, search: 'get'); + $cursor = $firstPage['next_cursor'] ?? null; + self::assertIsString($cursor); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Invalid cursor.'); + $tool->list(limit: 1, cursor: $cursor, sort: ApiMethodsPagination::SORT_METHOD_ASC, search: 'add'); + } + + public function testListRejectsCursorWhenModuleChanges(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + ); + + $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC, module: 'UsersManager'); + $cursor = $firstPage['next_cursor'] ?? null; + self::assertIsString($cursor); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Invalid cursor.'); + $tool->list(limit: 1, cursor: $cursor, sort: ApiMethodsPagination::SORT_METHOD_ASC, module: 'SitesManager'); + } + + public function testListAcceptsCursorWhenEquivalentModuleNormalizationIsUsed(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + ); + + $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC, module: ' UsersManager '); + $cursor = $firstPage['next_cursor'] ?? null; + self::assertIsString($cursor); + + $secondPage = $tool->list( + limit: 1, + cursor: $cursor, + sort: ApiMethodsPagination::SORT_METHOD_ASC, + module: 'usersmanager', + ); + + self::assertCount(1, $secondPage['methods']); + self::assertFalse($secondPage['has_more']); + self::assertNull($secondPage['next_cursor']); + self::assertSame(2, $secondPage['total_rows']); + } + + public function testListAcceptsCursorWhenEquivalentSearchNormalizationIsUsed(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + ); + + $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC, search: ' GET '); + $cursor = $firstPage['next_cursor'] ?? null; + self::assertIsString($cursor); + + $secondPage = $tool->list( + limit: 1, + cursor: $cursor, + sort: ApiMethodsPagination::SORT_METHOD_ASC, + search: 'get', + ); + + self::assertCount(1, $secondPage['methods']); + self::assertFalse($secondPage['has_more']); + self::assertNull($secondPage['next_cursor']); + self::assertSame(2, $secondPage['total_rows']); + } + + private function createQueryServiceStub(callable $callback): ApiMethodSummaryQueryServiceInterface + { + return new class ($callback) implements ApiMethodSummaryQueryServiceInterface { + /** @var callable(ApiMethodSummaryQueryRecord): array */ + private $callback; + + /** @param callable(ApiMethodSummaryQueryRecord): array $callback */ + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array + { + return ($this->callback)($query); + } + }; + } + + private function createExpandedQueryServiceStub(): ApiMethodSummaryQueryServiceInterface + { + return new class () implements ApiMethodSummaryQueryServiceInterface { + public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array + { + $records = [ + new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), + new ApiMethodSummaryRecord('SitesManager', 'deleteSite', 'SitesManager.deleteSite', []), + new ApiMethodSummaryRecord('SitesManager', 'isSiteNameUnique', 'SitesManager.isSiteNameUnique', []), + new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), + new ApiMethodSummaryRecord('UsersManager', 'getUsers', 'UsersManager.getUsers', []), + ]; + + return array_values(array_filter( + $records, + static function (ApiMethodSummaryRecord $record) use ($query): bool { + if ( + $query->accessMode === 'read' + && !str_starts_with(strtolower($record->action), 'get') + && !str_starts_with(strtolower($record->action), 'is') + ) { + return false; + } + + if ($query->module !== '' && strtolower($record->module) !== $query->module) { + return false; + } + + if ($query->search === '') { + return true; + } + + return str_contains(strtolower($record->method), $query->search) + || str_contains(strtolower($record->module), $query->search) + || str_contains(strtolower($record->action), $query->search); + }, + )); + } + }; + } +} diff --git a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php new file mode 100644 index 0000000..f11fbcb --- /dev/null +++ b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php @@ -0,0 +1,196 @@ +shouldIncludeMethodMetadataEntry('__documentation', [], false)); + self::assertFalse($service->shouldIncludeMethodMetadataEntry('getUsers', [], true)); + self::assertFalse($service->shouldIncludeMethodMetadataEntry('getUsers', 'invalid', false)); + self::assertTrue($service->shouldIncludeMethodMetadataEntry('getUsers', [], false)); + } + + public function testNormalizeParameterMetadataHandlesNoDefaultValueAsRequired(): void + { + $service = new ApiMethodSummaryQueryService(); + + $parameters = $service->normalizeParameterMetadata([ + 'idSite' => [ + 'default' => new NoDefaultValue(), + 'type' => 'int', + 'allowsNull' => false, + ], + ]); + + self::assertSame([ + [ + 'name' => 'idSite', + 'type' => 'int', + 'required' => true, + 'allowsNull' => false, + 'hasDefault' => false, + 'defaultValue' => null, + ], + ], $parameters); + } + + public function testNormalizeParameterMetadataPreservesScalarAndArrayDefaults(): void + { + $service = new ApiMethodSummaryQueryService(); + + $parameters = $service->normalizeParameterMetadata([ + 'period' => [ + 'default' => 'day', + 'type' => 'string', + 'allowsNull' => false, + ], + 'filters' => [ + 'default' => ['foo' => 'bar'], + 'type' => 'array', + 'allowsNull' => false, + ], + ]); + + self::assertSame('day', $parameters[0]['defaultValue']); + self::assertSame(['foo' => 'bar'], $parameters[1]['defaultValue']); + self::assertFalse($parameters[0]['required']); + self::assertTrue($parameters[0]['hasDefault']); + } + + public function testNormalizeParameterMetadataTreatsMissingDefaultAsRequired(): void + { + $service = new ApiMethodSummaryQueryService(); + + $parameters = $service->normalizeParameterMetadata([ + 'idSite' => [ + 'type' => 'int', + 'allowsNull' => false, + ], + ]); + + self::assertSame([ + [ + 'name' => 'idSite', + 'type' => 'int', + 'required' => true, + 'allowsNull' => false, + 'hasDefault' => false, + 'defaultValue' => null, + ], + ], $parameters); + } + + public function testNormalizeDefaultParameterValueReturnsNullForNonSerializableObject(): void + { + $service = new ApiMethodSummaryQueryService(); + + $nonSerializable = fopen('php://memory', 'rb'); + self::assertIsResource($nonSerializable); + + try { + $value = $service->normalizeDefaultParameterValue((object) ['stream' => $nonSerializable]); + self::assertNull($value); + } finally { + fclose($nonSerializable); + } + } + + public function testFilterRecordsAppliesReadAccessMode(): void + { + $service = new ApiMethodSummaryQueryService(); + + $records = $service->filterRecords( + $this->createMethodRecords(), + ApiMethodSummaryQueryRecord::fromInputs('read'), + ); + + self::assertSame( + ['API.getMatomoVersion', 'SitesManager.isSiteNameUnique', 'UsersManager.getUsers'], + array_values(array_map(static fn(ApiMethodSummaryRecord $record): string => $record->method, $records)), + ); + } + + public function testFilterRecordsAppliesCaseInsensitiveExactModuleFilter(): void + { + $service = new ApiMethodSummaryQueryService(); + + $records = $service->filterRecords( + $this->createMethodRecords(), + ApiMethodSummaryQueryRecord::fromInputs('full', ' usersmanager '), + ); + + self::assertSame( + ['UsersManager.addUser', 'UsersManager.getUsers'], + array_values(array_map(static fn(ApiMethodSummaryRecord $record): string => $record->method, $records)), + ); + } + + public function testFilterRecordsAppliesCaseInsensitiveSearchAcrossMethodActionAndModule(): void + { + $service = new ApiMethodSummaryQueryService(); + + $byMethod = $service->filterRecords( + $this->createMethodRecords(), + ApiMethodSummaryQueryRecord::fromInputs('full', null, 'getmatomo'), + ); + self::assertSame(['API.getMatomoVersion'], array_values(array_map( + static fn(ApiMethodSummaryRecord $record): string => $record->method, + $byMethod, + ))); + + $byAction = $service->filterRecords( + $this->createMethodRecords(), + ApiMethodSummaryQueryRecord::fromInputs('full', null, 'delete'), + ); + self::assertSame(['SitesManager.deleteSite'], array_values(array_map( + static fn(ApiMethodSummaryRecord $record): string => $record->method, + $byAction, + ))); + + $byModule = $service->filterRecords( + $this->createMethodRecords(), + ApiMethodSummaryQueryRecord::fromInputs('full', null, 'sitesmanager'), + ); + self::assertSame( + ['SitesManager.deleteSite', 'SitesManager.isSiteNameUnique'], + array_values(array_map(static fn(ApiMethodSummaryRecord $record): string => $record->method, $byModule)), + ); + } + + /** + * @return array + */ + private function createMethodRecords(): array + { + return [ + new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), + new ApiMethodSummaryRecord('SitesManager', 'deleteSite', 'SitesManager.deleteSite', []), + new ApiMethodSummaryRecord('SitesManager', 'isSiteNameUnique', 'SitesManager.isSiteNameUnique', []), + new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), + new ApiMethodSummaryRecord('UsersManager', 'getUsers', 'UsersManager.getUsers', []), + ]; + } +} diff --git a/tests/Unit/Support/Access/RawApiAccessModeTest.php b/tests/Unit/Support/Access/RawApiAccessModeTest.php new file mode 100644 index 0000000..5f414bf --- /dev/null +++ b/tests/Unit/Support/Access/RawApiAccessModeTest.php @@ -0,0 +1,46 @@ + Date: Wed, 11 Mar 2026 23:17:06 +0100 Subject: [PATCH 02/15] McpTool: matomo_api_get --- .../ApiMethodSummaryQueryServiceInterface.php | 7 + McpServerFactory.php | 19 ++ McpTools/ApiGet.php | 42 ++++ Schemas/Api/ApiGetToolInputSchema.php | 65 ++++++ Services/Api/ApiMethodSummaryQueryService.php | 62 +++++ config/config.php | 2 + docs/faq.md | 8 +- tests/Integration/McpTools/ApiGetTest.php | 211 ++++++++++++++++++ .../McpTools/ReportMetadataTest.php | 4 + .../McpTools/ReportProcessedTest.php | 4 + .../McpToolsContractBaselineTest.php | 18 ++ tests/Integration/McpToolsContractTest.php | 17 ++ tests/Unit/McpServerFactoryTest.php | 45 +++- tests/Unit/McpTools/ApiGetTest.php | 157 +++++++++++++ tests/Unit/McpTools/ApiListTest.php | 18 ++ .../Api/ApiMethodSummaryQueryServiceTest.php | 40 ++++ 16 files changed, 714 insertions(+), 5 deletions(-) create mode 100644 McpTools/ApiGet.php create mode 100644 Schemas/Api/ApiGetToolInputSchema.php create mode 100644 tests/Integration/McpTools/ApiGetTest.php create mode 100644 tests/Unit/McpTools/ApiGetTest.php diff --git a/Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php b/Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php index 928692f..d8bc78d 100644 --- a/Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php +++ b/Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php @@ -20,4 +20,11 @@ interface ApiMethodSummaryQueryServiceInterface * @return array */ public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array; + + public function getApiMethodSummaryBySelector( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ApiMethodSummaryRecord; } diff --git a/McpServerFactory.php b/McpServerFactory.php index 525c7cf..7179d2f 100644 --- a/McpServerFactory.php +++ b/McpServerFactory.php @@ -21,7 +21,9 @@ use Piwik\Config; use Piwik\Log\LoggerInterface; use Piwik\Plugin\Manager; +use Piwik\Plugins\McpServer\McpTools\ApiGet; use Piwik\Plugins\McpServer\McpTools\ApiList; +use Piwik\Plugins\McpServer\Schemas\Api\ApiGetToolInputSchema; use Piwik\Plugins\McpServer\Schemas\Api\ApiListToolInputSchema; use Piwik\Plugins\McpServer\Schemas\Api\ApiMethodSummaryToolOutputSchema; use Piwik\Plugins\McpServer\Server\Handler\Request\ObservedCallToolHandler; @@ -75,6 +77,23 @@ public function createServer(): Server if (RawApiAccessMode::allowsToolRegistration($rawApiAccessMode)) { // This tool is registered manually (not via attribute discovery) // so registration can be gated by the raw API access mode. + $builder->addTool( + [ApiGet::class, 'get'], + ApiGet::TOOL_NAME, + "Use when: you already know the Matomo API method name and need its exact signature.\n" + . "Purpose: return one authoritative API method summary with parameter metadata.\n" + . "Do not use: for broad discovery across APIs; use " . ApiList::TOOL_NAME . ' instead.', + new ToolAnnotations( + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + ), + ApiGetToolInputSchema::SCHEMA, + null, + null, + ApiMethodSummaryToolOutputSchema::ITEM, + ); $builder->addTool( [ApiList::class, 'list'], ApiList::TOOL_NAME, diff --git a/McpTools/ApiGet.php b/McpTools/ApiGet.php new file mode 100644 index 0000000..9d9e5e9 --- /dev/null +++ b/McpTools/ApiGet.php @@ -0,0 +1,42 @@ +queryService->getApiMethodSummaryBySelector( + RawApiAccessMode::normalize(Config::getInstance()->McpServer['raw_api_access_mode'] ?? null), + $method, + $module, + $action, + )->toArray(); + } +} diff --git a/Schemas/Api/ApiGetToolInputSchema.php b/Schemas/Api/ApiGetToolInputSchema.php new file mode 100644 index 0000000..6d58f80 --- /dev/null +++ b/Schemas/Api/ApiGetToolInputSchema.php @@ -0,0 +1,65 @@ + 'object', + 'properties' => [ + 'method' => [ + 'type' => 'string', + 'minLength' => 1, + 'description' => 'Exact Matomo API method name as Module.action.', + ], + 'module' => [ + 'type' => 'string', + 'minLength' => 1, + 'description' => 'Exact Matomo API module name.', + ], + 'action' => [ + 'type' => 'string', + 'minLength' => 1, + 'description' => 'Exact Matomo API action name.', + ], + ], + // Selector truth table: + // valid: method + // valid: module + action + // invalid: no selector, partial module/action selector, or method combined + // with module/action + 'not' => [ + 'anyOf' => [ + [ + 'not' => [ + 'anyOf' => [ + ['required' => ['method']], + ['required' => ['module']], + ['required' => ['action']], + ], + ], + ], + ['required' => ['method', 'module']], + ['required' => ['method', 'action']], + [ + 'required' => ['module'], + 'not' => ['required' => ['action']], + ], + [ + 'required' => ['action'], + 'not' => ['required' => ['module']], + ], + ], + ], + 'additionalProperties' => false, + ]; +} diff --git a/Services/Api/ApiMethodSummaryQueryService.php b/Services/Api/ApiMethodSummaryQueryService.php index b078716..96fb2a5 100644 --- a/Services/Api/ApiMethodSummaryQueryService.php +++ b/Services/Api/ApiMethodSummaryQueryService.php @@ -11,6 +11,7 @@ namespace Piwik\Plugins\McpServer\Services\Api; +use Matomo\Dependencies\McpServer\Mcp\Exception\ToolCallException; use Piwik\API\DocumentationGenerator; use Piwik\API\NoDefaultValue; use Piwik\API\Proxy; @@ -29,6 +30,25 @@ public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array return $this->filterRecords($this->loadApiMethodSummaries(), $query); } + public function getApiMethodSummaryBySelector( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ApiMethodSummaryRecord { + $records = $this->filterRecords( + $this->loadApiMethodSummaries(), + ApiMethodSummaryQueryRecord::fromInputs($accessMode), + ); + + $selectedRecord = $this->findApiMethodSummaryRecord($records, $method, $module, $action); + if ($selectedRecord === null) { + throw new ToolCallException('API method not found or unavailable.'); + } + + return $selectedRecord; + } + /** * Public for testability and to share normalization contract across MCP tools. * @@ -90,6 +110,43 @@ public function filterRecords(array $records, ApiMethodSummaryQueryRecord $query return $records; } + /** + * Public for testability and to share normalization contract across MCP tools. + * + * @param array $records + */ + public function findApiMethodSummaryRecord( + array $records, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ?ApiMethodSummaryRecord { + $normalizedMethod = $this->normalizeSelectorValue($method); + if ($normalizedMethod !== '') { + foreach ($records as $record) { + if ($this->normalizeSelectorValue($record->method) === $normalizedMethod) { + return $record; + } + } + + return null; + } + + $normalizedModule = $this->normalizeSelectorValue($module); + $normalizedAction = $this->normalizeSelectorValue($action); + + foreach ($records as $record) { + if ( + $this->normalizeSelectorValue($record->module) === $normalizedModule + && $this->normalizeSelectorValue($record->action) === $normalizedAction + ) { + return $record; + } + } + + return null; + } + /** * Public for testability and to share normalization contract across MCP tools. * @@ -226,4 +283,9 @@ private function filterBySearch(array $records, string $searchTerm): array static fn(ApiMethodSummaryRecord $record): bool => str_contains(strtolower($record->method), $searchTerm) )); } + + private function normalizeSelectorValue(?string $value): string + { + return strtolower(trim((string) $value)); + } } diff --git a/config/config.php b/config/config.php index 87b8ae8..4732f53 100644 --- a/config/config.php +++ b/config/config.php @@ -28,6 +28,7 @@ use Piwik\Plugins\McpServer\Contracts\Ports\Sites\SiteSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; use Piwik\Plugins\McpServer\McpServerFactory; +use Piwik\Plugins\McpServer\McpTools\ApiGet; use Piwik\Plugins\McpServer\McpTools\ApiList; use Piwik\Plugins\McpServer\Services\Api\ApiMethodSummaryQueryService; use Piwik\Plugins\McpServer\Services\Dimensions\CoreCustomDimensionsGateway; @@ -81,6 +82,7 @@ DbSessionStore::class => DI::autowire(), McpServerFactory::class => DI::autowire(), PaginatedCollectionResponder::class => DI::autowire(), + ApiGet::class => DI::autowire(), ApiList::class => DI::autowire(), Tasks::class => DI::autowire(), ]; diff --git a/docs/faq.md b/docs/faq.md index 7ca045b..fd2206b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -34,10 +34,10 @@ Configure raw Matomo API tool access in `config/config.ini.php`: raw_api_access_mode = none ``` -- `raw_api_access_mode`: Controls raw API discovery tool visibility for `matomo_api_list`. -- `none`: hides `matomo_api_list` (default). -- `read`: shows `matomo_api_list` and currently returns only API actions with `get`/`is` prefix. This prefix-based filter is a temporary heuristic and may be replaced by a more accurate read/write classification in the future. -- `full`: shows `matomo_api_list` and returns all discoverable API actions. +- `raw_api_access_mode`: Controls raw API discovery tool visibility for `matomo_api_list` and `matomo_api_get`. +- `none`: hides `matomo_api_list` and `matomo_api_get` (default). +- `read`: shows `matomo_api_list` and `matomo_api_get`, and currently returns only API actions with `get`/`is` prefix. This prefix-based filter is a temporary heuristic and may be replaced by a more accurate read/write classification in the future. +- `full`: shows `matomo_api_list` and `matomo_api_get`, and returns all discoverable API actions. ## Enabling MCP diff --git a/tests/Integration/McpTools/ApiGetTest.php b/tests/Integration/McpTools/ApiGetTest.php new file mode 100644 index 0000000..a2dc7aa --- /dev/null +++ b/tests/Integration/McpTools/ApiGetTest.php @@ -0,0 +1,211 @@ +McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['method' => ' API.getMatomoVersion '], + __METHOD__, + ); + + self::assertSame('API', $content['module'] ?? null); + self::assertSame('getMatomoVersion', $content['action'] ?? null); + self::assertSame('API.getMatomoVersion', $content['method'] ?? null); + self::assertIsArray($content['parameters'] ?? null); + } + + public function testFullModeReturnsKnownMutatingMethodByModuleAndActionSelectors(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['module' => ' usersmanager ', 'action' => ' adduser '], + __METHOD__, + ); + + self::assertSame('UsersManager', $content['module'] ?? null); + self::assertSame('addUser', $content['action'] ?? null); + self::assertSame('UsersManager.addUser', $content['method'] ?? null); + self::assertIsArray($content['parameters'] ?? null); + } + + public function testReadModeRejectsWriteOnlyMethod(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['method' => 'UsersManager.addUser'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testRejectsIncompleteSplitSelectorAtSchemaLevel(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $message = McpTestHelper::callToolExpectInvalidParams( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['module' => 'UsersManager'], + __METHOD__, + ); + + self::assertStringContainsString("Invalid parameters for tool '" . ApiGet::TOOL_NAME . "':", $message->message); + } + + public function testRejectsMissingSelectorAtSchemaLevel(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $message = McpTestHelper::callToolExpectInvalidParams( + $server, + $sessionId, + ApiGet::TOOL_NAME, + [], + __METHOD__, + ); + + self::assertStringContainsString("Invalid parameters for tool '" . ApiGet::TOOL_NAME . "':", $message->message); + } + + public function testRejectsMixedSelectorStyleAtSchemaLevel(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeCallToolRequest( + ApiGet::TOOL_NAME, + ['method' => 'API.getMatomoVersion', 'module' => 'API'], + __METHOD__, + ); + + $response = McpTestHelper::postJson($server, $payload, ['Mcp-Session-Id' => $sessionId]); + $message = McpTestHelper::decodeError($response); + + self::assertSame(JsonRpcError::INVALID_PARAMS, $message->code); + self::assertStringContainsString( + "Invalid parameters for tool '" . ApiGet::TOOL_NAME . "':", + $message->message ?? '', + ); + } + + public function testSchemaDeclaresFlatSelectorsWithoutTopLevelCombinators(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeListToolsRequest(__METHOD__); + + $response = McpTestHelper::postJson($server, $payload, ['Mcp-Session-Id' => $sessionId]); + $message = McpTestHelper::decodeResponse($response); + $result = McpTestHelper::parseListTools($message); + + $apiGetTool = null; + foreach ($result->tools as $tool) { + if ($tool->name === ApiGet::TOOL_NAME) { + $apiGetTool = $tool; + break; + } + } + + self::assertNotNull($apiGetTool); + /** @var array $inputSchema */ + $inputSchema = $apiGetTool->inputSchema; + self::assertArrayNotHasKey('oneOf', $inputSchema); + self::assertArrayNotHasKey('allOf', $inputSchema); + self::assertArrayNotHasKey('anyOf', $inputSchema); + self::assertArrayHasKey('not', $inputSchema); + self::assertIsArray($inputSchema['not']); + + $notSchema = $inputSchema['not']; + self::assertArrayHasKey('anyOf', $notSchema); + self::assertIsArray($notSchema['anyOf']); + + $properties = $inputSchema['properties'] ?? null; + self::assertIsArray($properties); + self::assertArrayHasKey('method', $properties); + self::assertArrayHasKey('module', $properties); + self::assertArrayHasKey('action', $properties); + } + + public function testNoneModeHidesAndRejectsToolCall(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; + self::assertNotContains(ApiGet::TOOL_NAME, $this->listToolNamesForCurrentConfig()); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeCallToolRequest( + ApiGet::TOOL_NAME, + ['method' => 'API.getMatomoVersion'], + __METHOD__, + ); + $response = McpTestHelper::postJson($server, $payload, ['Mcp-Session-Id' => $sessionId]); + $message = McpTestHelper::decodeError($response); + + self::assertSame(JsonRpcError::METHOD_NOT_FOUND, $message->code); + } + + /** + * @return list + */ + private function listToolNamesForCurrentConfig(): array + { + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeListToolsRequest(__METHOD__); + + $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) => $tool->name, $result->tools)); + } +} diff --git a/tests/Integration/McpTools/ReportMetadataTest.php b/tests/Integration/McpTools/ReportMetadataTest.php index f40a6a2..5b48eed 100644 --- a/tests/Integration/McpTools/ReportMetadataTest.php +++ b/tests/Integration/McpTools/ReportMetadataTest.php @@ -558,6 +558,10 @@ public function testSchemaDeclaresSelectorRulesWithoutTopLevelCombinators(): voi self::assertArrayNotHasKey('anyOf', $inputSchema); self::assertArrayHasKey('not', $inputSchema); self::assertIsArray($inputSchema['not']); + + $notSchema = $inputSchema['not']; + self::assertArrayHasKey('anyOf', $notSchema); + self::assertIsArray($notSchema['anyOf']); } /** diff --git a/tests/Integration/McpTools/ReportProcessedTest.php b/tests/Integration/McpTools/ReportProcessedTest.php index 00d7612..51b4764 100644 --- a/tests/Integration/McpTools/ReportProcessedTest.php +++ b/tests/Integration/McpTools/ReportProcessedTest.php @@ -609,6 +609,10 @@ public function testSchemaDeclaresTopLevelGoalParameters(): void self::assertArrayHasKey('not', $inputSchema); self::assertIsArray($inputSchema['not']); + $notSchema = $inputSchema['not']; + self::assertArrayHasKey('anyOf', $notSchema); + self::assertIsArray($notSchema['anyOf']); + $properties = $inputSchema['properties'] ?? null; self::assertIsArray($properties); self::assertArrayHasKey('goalMetricsMode', $properties); diff --git a/tests/Integration/McpToolsContractBaselineTest.php b/tests/Integration/McpToolsContractBaselineTest.php index 3cd5b79..b2d84f0 100644 --- a/tests/Integration/McpToolsContractBaselineTest.php +++ b/tests/Integration/McpToolsContractBaselineTest.php @@ -16,6 +16,7 @@ use Piwik\Plugins\API\API as ApiModuleApi; use Piwik\Plugins\CustomDimensions\API as CustomDimensionsApi; use Piwik\Plugins\Goals\API as GoalsApi; +use Piwik\Plugins\McpServer\McpTools\ApiGet; use Piwik\Plugins\McpServer\McpTools\DimensionGet; use Piwik\Plugins\McpServer\McpTools\DimensionList; use Piwik\Plugins\McpServer\McpTools\GoalGet; @@ -255,6 +256,23 @@ public function testApiListSuccessShapeInReadMode(): void self::assertNotEmpty($content['methods'] ?? []); } + public function testApiGetSuccessShapeInReadMode(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['method' => 'API.getMatomoVersion'], + __METHOD__, + ); + + ContractShapeAssert::assertMatchesSchema(ApiMethodSummaryToolOutputSchema::ITEM, $content); + } + /** * @return array}> */ diff --git a/tests/Integration/McpToolsContractTest.php b/tests/Integration/McpToolsContractTest.php index 1972c75..742ea18 100644 --- a/tests/Integration/McpToolsContractTest.php +++ b/tests/Integration/McpToolsContractTest.php @@ -148,6 +148,7 @@ public function testRawApiListToolIsHiddenWhenRawAccessModeIsNone(): void Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; $toolsByName = $this->listToolsByNameForCurrentConfig(); + self::assertArrayNotHasKey('matomo_api_get', $toolsByName); self::assertArrayNotHasKey('matomo_api_list', $toolsByName); } @@ -156,6 +157,14 @@ public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessM Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; $toolsByName = $this->listToolsByNameForCurrentConfig(); + self::assertArrayHasKey('matomo_api_get', $toolsByName); + $getTool = $toolsByName['matomo_api_get']; + self::assertNotNull($getTool->annotations); + self::assertTrue($getTool->annotations->readOnlyHint); + self::assertFalse($getTool->annotations->destructiveHint); + self::assertTrue($getTool->annotations->idempotentHint); + self::assertFalse($getTool->annotations->openWorldHint); + self::assertArrayHasKey('matomo_api_list', $toolsByName); $tool = $toolsByName['matomo_api_list']; self::assertNotNull($tool->annotations); @@ -170,6 +179,14 @@ public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessM Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; $toolsByName = $this->listToolsByNameForCurrentConfig(); + self::assertArrayHasKey('matomo_api_get', $toolsByName); + $getTool = $toolsByName['matomo_api_get']; + self::assertNotNull($getTool->annotations); + self::assertTrue($getTool->annotations->readOnlyHint); + self::assertFalse($getTool->annotations->destructiveHint); + self::assertTrue($getTool->annotations->idempotentHint); + self::assertFalse($getTool->annotations->openWorldHint); + self::assertArrayHasKey('matomo_api_list', $toolsByName); $tool = $toolsByName['matomo_api_list']; self::assertNotNull($tool->annotations); diff --git a/tests/Unit/McpServerFactoryTest.php b/tests/Unit/McpServerFactoryTest.php index 57f9126..843b348 100644 --- a/tests/Unit/McpServerFactoryTest.php +++ b/tests/Unit/McpServerFactoryTest.php @@ -12,6 +12,7 @@ namespace Piwik\Plugins\McpServer\tests\Unit; use Matomo\Dependencies\McpServer\Mcp\Schema\JsonRpc\Error as JsonRpcError; +use Matomo\Dependencies\McpServer\Mcp\Schema\Tool; use Matomo\Dependencies\McpServer\Mcp\Server\Session\InMemorySessionStore; use PHPUnit\Framework\TestCase; use Piwik\Config; @@ -400,10 +401,12 @@ public function testRawApiListToolIsHiddenWhenRawAccessModeIsMissingOrNone(): vo { Config::getInstance()->McpServer = []; $toolsWhenMissing = $this->listToolNamesForCurrentConfig(); + self::assertNotContains('matomo_api_get', $toolsWhenMissing); self::assertNotContains('matomo_api_list', $toolsWhenMissing); Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; $toolsWhenNone = $this->listToolNamesForCurrentConfig(); + self::assertNotContains('matomo_api_get', $toolsWhenNone); self::assertNotContains('matomo_api_list', $toolsWhenNone); } @@ -411,17 +414,52 @@ public function testRawApiListToolIsVisibleWhenRawAccessModeIsReadOrFull(): void { Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; $toolsWhenRead = $this->listToolNamesForCurrentConfig(); + self::assertContains('matomo_api_get', $toolsWhenRead); self::assertContains('matomo_api_list', $toolsWhenRead); Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; $toolsWhenFull = $this->listToolNamesForCurrentConfig(); + self::assertContains('matomo_api_get', $toolsWhenFull); self::assertContains('matomo_api_list', $toolsWhenFull); } + public function testRawApiGetToolHasFullAnnotationsWhenVisible(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + $toolsWhenRead = $this->listToolsByNameForCurrentConfig(); + + self::assertArrayHasKey('matomo_api_get', $toolsWhenRead); + $toolWhenRead = $toolsWhenRead['matomo_api_get']; + self::assertNotNull($toolWhenRead->annotations); + self::assertTrue($toolWhenRead->annotations->readOnlyHint); + self::assertFalse($toolWhenRead->annotations->destructiveHint); + self::assertTrue($toolWhenRead->annotations->idempotentHint); + self::assertFalse($toolWhenRead->annotations->openWorldHint); + + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + $toolsWhenFull = $this->listToolsByNameForCurrentConfig(); + + self::assertArrayHasKey('matomo_api_get', $toolsWhenFull); + $toolWhenFull = $toolsWhenFull['matomo_api_get']; + self::assertNotNull($toolWhenFull->annotations); + self::assertTrue($toolWhenFull->annotations->readOnlyHint); + self::assertFalse($toolWhenFull->annotations->destructiveHint); + self::assertTrue($toolWhenFull->annotations->idempotentHint); + self::assertFalse($toolWhenFull->annotations->openWorldHint); + } + /** * @return list */ private function listToolNamesForCurrentConfig(): array + { + return array_keys($this->listToolsByNameForCurrentConfig()); + } + + /** + * @return array + */ + private function listToolsByNameForCurrentConfig(): array { $factory = new McpServerFactory( $this->createMock(LoggerInterface::class), @@ -437,6 +475,11 @@ private function listToolNamesForCurrentConfig(): array $message = McpTestHelper::decodeResponse($response); $result = McpTestHelper::parseListTools($message); - return array_values(array_map(static fn($tool) => $tool->name, $result->tools)); + $toolsByName = []; + foreach ($result->tools as $tool) { + $toolsByName[$tool->name] = $tool; + } + + return $toolsByName; } } diff --git a/tests/Unit/McpTools/ApiGetTest.php b/tests/Unit/McpTools/ApiGetTest.php new file mode 100644 index 0000000..94d28fd --- /dev/null +++ b/tests/Unit/McpTools/ApiGetTest.php @@ -0,0 +1,157 @@ +|null */ + private ?array $originalMcpServerConfig = null; + + public function setUp(): void + { + parent::setUp(); + + $originalConfig = Config::getInstance()->McpServer ?? null; + $this->originalMcpServerConfig = is_array($originalConfig) ? $originalConfig : null; + } + + public function tearDown(): void + { + Config::getInstance()->McpServer = $this->originalMcpServerConfig; + + parent::tearDown(); + } + + public function testGetReturnsRecordFromMethodSelector(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + $captured = new stdClass(); + $captured->values = []; + + $tool = new ApiGet( + new class ($captured) implements ApiMethodSummaryQueryServiceInterface { + public function __construct(private stdClass $captured) + { + } + + public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array + { + return []; + } + + public function getApiMethodSummaryBySelector( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ApiMethodSummaryRecord { + $this->captured->values = [ + 'accessMode' => $accessMode, + 'method' => $method, + 'module' => $module, + 'action' => $action, + ]; + + return new ApiMethodSummaryRecord( + module: 'API', + action: 'getMatomoVersion', + method: 'API.getMatomoVersion', + parameters: [], + ); + } + }, + ); + + $actual = $tool->get(method: ' API.getMatomoVersion '); + + self::assertSame([ + 'module' => 'API', + 'action' => 'getMatomoVersion', + 'method' => 'API.getMatomoVersion', + 'parameters' => [], + ], $actual); + /** @var array $capturedValues */ + $capturedValues = $captured->values; + self::assertSame('read', $capturedValues['accessMode']); + self::assertSame(' API.getMatomoVersion ', $capturedValues['method']); + self::assertNull($capturedValues['module']); + self::assertNull($capturedValues['action']); + } + + public function testGetReturnsRecordFromModuleAndActionSelectors(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + $captured = new stdClass(); + $captured->values = []; + + $tool = new ApiGet( + new class ($captured) implements ApiMethodSummaryQueryServiceInterface { + public function __construct(private stdClass $captured) + { + } + + public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array + { + return []; + } + + public function getApiMethodSummaryBySelector( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ApiMethodSummaryRecord { + $this->captured->values = [ + 'accessMode' => $accessMode, + 'method' => $method, + 'module' => $module, + 'action' => $action, + ]; + + return new ApiMethodSummaryRecord( + module: 'UsersManager', + action: 'addUser', + method: 'UsersManager.addUser', + parameters: [], + ); + } + }, + ); + + $actual = $tool->get(module: ' UsersManager ', action: ' addUser '); + + self::assertSame([ + 'module' => 'UsersManager', + 'action' => 'addUser', + 'method' => 'UsersManager.addUser', + 'parameters' => [], + ], $actual); + /** @var array $capturedValues */ + $capturedValues = $captured->values; + self::assertSame('full', $capturedValues['accessMode']); + self::assertNull($capturedValues['method']); + self::assertSame(' UsersManager ', $capturedValues['module']); + self::assertSame(' addUser ', $capturedValues['action']); + } +} diff --git a/tests/Unit/McpTools/ApiListTest.php b/tests/Unit/McpTools/ApiListTest.php index 98e910c..4f2f2e6 100644 --- a/tests/Unit/McpTools/ApiListTest.php +++ b/tests/Unit/McpTools/ApiListTest.php @@ -297,6 +297,15 @@ public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array { return ($this->callback)($query); } + + public function getApiMethodSummaryBySelector( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ApiMethodSummaryRecord { + throw new \BadMethodCallException('Not used in ApiList tests.'); + } }; } @@ -338,6 +347,15 @@ static function (ApiMethodSummaryRecord $record) use ($query): bool { }, )); } + + public function getApiMethodSummaryBySelector( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ApiMethodSummaryRecord { + throw new \BadMethodCallException('Not used in ApiList tests.'); + } }; } } diff --git a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php index f11fbcb..f6ebbb2 100644 --- a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php +++ b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php @@ -180,6 +180,46 @@ public function testFilterRecordsAppliesCaseInsensitiveSearchAcrossMethodActionA ); } + public function testFindApiMethodSummaryRecordMatchesMethodCaseInsensitively(): void + { + $service = new ApiMethodSummaryQueryService(); + + $record = $service->findApiMethodSummaryRecord( + $this->createMethodRecords(), + ' usersmanager.getusers ', + ); + + self::assertInstanceOf(ApiMethodSummaryRecord::class, $record); + self::assertSame('UsersManager.getUsers', $record->method); + } + + public function testFindApiMethodSummaryRecordMatchesModuleAndActionCaseInsensitively(): void + { + $service = new ApiMethodSummaryQueryService(); + + $record = $service->findApiMethodSummaryRecord( + $this->createMethodRecords(), + null, + ' usersmanager ', + ' adduser ', + ); + + self::assertInstanceOf(ApiMethodSummaryRecord::class, $record); + self::assertSame('UsersManager.addUser', $record->method); + } + + public function testFindApiMethodSummaryRecordReturnsNullWhenNoMatchExists(): void + { + $service = new ApiMethodSummaryQueryService(); + + $record = $service->findApiMethodSummaryRecord( + $this->createMethodRecords(), + 'API.missingMethod', + ); + + self::assertNull($record); + } + /** * @return array */ From cd1529c9f03495ca526eb3f1ab844ece94aa6698 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Thu, 12 Mar 2026 00:12:34 +0100 Subject: [PATCH 03/15] McpTool: matomo_api_call --- .../Api/ApiCallQueryServiceInterface.php | 28 ++ .../Ports/Api/CoreApiCallGatewayInterface.php | 20 + Contracts/Records/Api/ApiCallRecord.php | 39 ++ McpServerFactory.php | 30 ++ McpTools/ApiCall.php | 48 +++ Schemas/Api/ApiCallToolInputSchema.php | 65 +++ Schemas/Api/ApiCallToolOutputSchema.php | 25 ++ Services/Api/ApiCallQueryService.php | 223 ++++++++++ Services/Api/CoreApiCallGateway.php | 62 +++ config/config.php | 8 + docs/faq.md | 8 +- tests/Integration/McpTools/ApiCallTest.php | 307 ++++++++++++++ .../McpToolsContractBaselineTest.php | 19 + tests/Integration/McpToolsContractTest.php | 18 + tests/Unit/McpServerFactoryTest.php | 29 ++ tests/Unit/McpTools/ApiCallTest.php | 151 +++++++ .../Unit/Services/ApiCallQueryServiceTest.php | 388 ++++++++++++++++++ .../Unit/Services/CoreApiCallGatewayTest.php | 68 +++ 18 files changed, 1532 insertions(+), 4 deletions(-) create mode 100644 Contracts/Ports/Api/ApiCallQueryServiceInterface.php create mode 100644 Contracts/Ports/Api/CoreApiCallGatewayInterface.php create mode 100644 Contracts/Records/Api/ApiCallRecord.php create mode 100644 McpTools/ApiCall.php create mode 100644 Schemas/Api/ApiCallToolInputSchema.php create mode 100644 Schemas/Api/ApiCallToolOutputSchema.php create mode 100644 Services/Api/ApiCallQueryService.php create mode 100644 Services/Api/CoreApiCallGateway.php create mode 100644 tests/Integration/McpTools/ApiCallTest.php create mode 100644 tests/Unit/McpTools/ApiCallTest.php create mode 100644 tests/Unit/Services/ApiCallQueryServiceTest.php create mode 100644 tests/Unit/Services/CoreApiCallGatewayTest.php diff --git a/Contracts/Ports/Api/ApiCallQueryServiceInterface.php b/Contracts/Ports/Api/ApiCallQueryServiceInterface.php new file mode 100644 index 0000000..c9cb824 --- /dev/null +++ b/Contracts/Ports/Api/ApiCallQueryServiceInterface.php @@ -0,0 +1,28 @@ +|null $parameters + */ + public function callApi( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ?array $parameters = null, + ): ApiCallRecord; +} diff --git a/Contracts/Ports/Api/CoreApiCallGatewayInterface.php b/Contracts/Ports/Api/CoreApiCallGatewayInterface.php new file mode 100644 index 0000000..cec141b --- /dev/null +++ b/Contracts/Ports/Api/CoreApiCallGatewayInterface.php @@ -0,0 +1,20 @@ + $parameters + */ + public function call(string $method, array $parameters): mixed; +} diff --git a/Contracts/Records/Api/ApiCallRecord.php b/Contracts/Records/Api/ApiCallRecord.php new file mode 100644 index 0000000..dfb364b --- /dev/null +++ b/Contracts/Records/Api/ApiCallRecord.php @@ -0,0 +1,39 @@ + $this->result, + 'resolvedMethod' => $this->resolvedMethod->toArray(), + ]; + } +} diff --git a/McpServerFactory.php b/McpServerFactory.php index 7179d2f..712173f 100644 --- a/McpServerFactory.php +++ b/McpServerFactory.php @@ -21,8 +21,11 @@ use Piwik\Config; use Piwik\Log\LoggerInterface; use Piwik\Plugin\Manager; +use Piwik\Plugins\McpServer\McpTools\ApiCall; use Piwik\Plugins\McpServer\McpTools\ApiGet; use Piwik\Plugins\McpServer\McpTools\ApiList; +use Piwik\Plugins\McpServer\Schemas\Api\ApiCallToolInputSchema; +use Piwik\Plugins\McpServer\Schemas\Api\ApiCallToolOutputSchema; use Piwik\Plugins\McpServer\Schemas\Api\ApiGetToolInputSchema; use Piwik\Plugins\McpServer\Schemas\Api\ApiListToolInputSchema; use Piwik\Plugins\McpServer\Schemas\Api\ApiMethodSummaryToolOutputSchema; @@ -75,6 +78,33 @@ public function createServer(): Server $rawApiAccessMode = $this->resolveRawApiAccessMode(); if (RawApiAccessMode::allowsToolRegistration($rawApiAccessMode)) { + $rawApiCallDestructiveHint = $rawApiAccessMode === RawApiAccessMode::FULL; + $builder->addTool( + [ApiCall::class, 'call'], + ApiCall::TOOL_NAME, + "Use when: you need to execute a known Matomo API method directly.\n" + . "Purpose: call one allowed API method and return its result plus the resolved method metadata.\n" + . "Next: use " . ApiGet::TOOL_NAME . ' or ' . ApiList::TOOL_NAME + . ' first if you still need to confirm the method signature.', + // Keep these conservative defaults for raw API calls: + // - readOnlyHint=false because even "read" API methods can trigger + // archive/materialization side effects depending on Matomo runtime config. + // - destructiveHint=false in read mode because those effects are additive, + // not destructive mutations; full mode remains destructive because it can + // call arbitrary mutating methods. + // - idempotentHint=false because repeated identical calls cannot guarantee + // zero additional environmental effect across archive configurations. + new ToolAnnotations( + readOnlyHint: false, + destructiveHint: $rawApiCallDestructiveHint, + idempotentHint: false, + openWorldHint: false, + ), + ApiCallToolInputSchema::SCHEMA, + null, + null, + ApiCallToolOutputSchema::ITEM, + ); // This tool is registered manually (not via attribute discovery) // so registration can be gated by the raw API access mode. $builder->addTool( diff --git a/McpTools/ApiCall.php b/McpTools/ApiCall.php new file mode 100644 index 0000000..b17ca75 --- /dev/null +++ b/McpTools/ApiCall.php @@ -0,0 +1,48 @@ +|null $parameters + * @return ApiCallArray + */ + public function call( + ?string $method = null, + ?string $module = null, + ?string $action = null, + ?array $parameters = null, + ): array { + return $this->queryService->callApi( + RawApiAccessMode::normalize(Config::getInstance()->McpServer['raw_api_access_mode'] ?? null), + $method, + $module, + $action, + $parameters, + )->toArray(); + } +} diff --git a/Schemas/Api/ApiCallToolInputSchema.php b/Schemas/Api/ApiCallToolInputSchema.php new file mode 100644 index 0000000..9f0d0b5 --- /dev/null +++ b/Schemas/Api/ApiCallToolInputSchema.php @@ -0,0 +1,65 @@ + 'object', + 'properties' => [ + 'method' => [ + 'type' => 'string', + 'minLength' => 1, + 'description' => 'Exact Matomo API method name as Module.action.', + ], + 'module' => [ + 'type' => 'string', + 'minLength' => 1, + 'description' => 'Exact Matomo API module name.', + ], + 'action' => [ + 'type' => 'string', + 'minLength' => 1, + 'description' => 'Exact Matomo API action name.', + ], + 'parameters' => [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'Optional Matomo API parameters for the selected method.', + ], + ], + 'not' => [ + 'anyOf' => [ + [ + 'not' => [ + 'anyOf' => [ + ['required' => ['method']], + ['required' => ['module']], + ['required' => ['action']], + ], + ], + ], + ['required' => ['method', 'module']], + ['required' => ['method', 'action']], + [ + 'required' => ['module'], + 'not' => ['required' => ['action']], + ], + [ + 'required' => ['action'], + 'not' => ['required' => ['module']], + ], + ], + ], + 'additionalProperties' => false, + ]; +} diff --git a/Schemas/Api/ApiCallToolOutputSchema.php b/Schemas/Api/ApiCallToolOutputSchema.php new file mode 100644 index 0000000..612aac1 --- /dev/null +++ b/Schemas/Api/ApiCallToolOutputSchema.php @@ -0,0 +1,25 @@ + 'object', + 'properties' => [ + 'result' => [], + 'resolvedMethod' => ApiMethodSummaryToolOutputSchema::ITEM, + ], + 'required' => ['result', 'resolvedMethod'], + 'additionalProperties' => false, + ]; +} diff --git a/Services/Api/ApiCallQueryService.php b/Services/Api/ApiCallQueryService.php new file mode 100644 index 0000000..eeaaa8e --- /dev/null +++ b/Services/Api/ApiCallQueryService.php @@ -0,0 +1,223 @@ + */ + private const RESERVED_PARAMETER_KEYS = [ + 'method' => true, + 'module' => true, + 'action' => true, + 'format' => true, + 'serialize' => true, + 'token_auth' => true, + 'force_api_session' => true, + ]; + + public function __construct( + private ApiMethodSummaryQueryServiceInterface $apiMethodSummaryQueryService, + private CoreApiCallGatewayInterface $coreApiCallGateway, + ) { + } + + public function callApi( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ?array $parameters = null, + ): ApiCallRecord { + $resolvedMethod = $this->apiMethodSummaryQueryService->getApiMethodSummaryBySelector( + $accessMode, + $method, + $module, + $action, + ); + $sanitizedParameters = $this->sanitizeParameters($parameters); + + try { + $result = $this->coreApiCallGateway->call($resolvedMethod->method, $sanitizedParameters); + } catch (AccessDeniedLikeException) { + throw new ToolCallException('No access to API method.'); + } catch (CoreApiRequestException $e) { + throw new ToolCallException($this->buildFailureMessage($e)); + } + + return new ApiCallRecord( + $this->normalizeValue($result, 'API response'), + $resolvedMethod, + ); + } + + /** + * @param array|null $parameters + * @return array + */ + private function sanitizeParameters(?array $parameters): array + { + $parameters ??= []; + + foreach ($parameters as $key => $_value) { + if (isset(self::RESERVED_PARAMETER_KEYS[strtolower($key)])) { + throw new ToolCallException("Unsupported parameters key '{$key}'."); + } + } + + return $parameters; + } + + private function normalizeValue(mixed $value, string $context): mixed + { + if ($value === null || is_scalar($value)) { + return $value; + } + + if ($value instanceof DataTableInterface) { + try { + $renderer = new Json(); + $renderer->setTable($value); + $json = $renderer->render(); + + return json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable) { + throw new ToolCallException($context . ' is invalid.'); + } + } + + if (is_array($value)) { + $normalized = []; + foreach ($value as $key => $item) { + $normalized[$key] = $this->normalizeValue($item, $context); + } + + return $normalized; + } + + try { + $encoded = json_encode($value, JSON_THROW_ON_ERROR); + return json_decode($encoded, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable) { + throw new ToolCallException($context . ' is invalid.'); + } + } + + private function buildFailureMessage(CoreApiRequestException $e): string + { + $detail = $this->extractSafeFailureDetail($e); + if ($detail === null) { + return self::GENERIC_FAILURE_MESSAGE; + } + + return self::DETAILED_FAILURE_PREFIX . $detail; + } + + private function extractSafeFailureDetail(CoreApiRequestException $e): ?string + { + $previous = $e->getPrevious(); + if (!$previous instanceof \Throwable) { + return null; + } + + $message = trim(preg_replace('/\s+/', ' ', $previous->getMessage()) ?? ''); + if ($message === '') { + return null; + } + + $message = rtrim($message, ". \t\n\r\0\x0B"); + if ($message === '') { + return null; + } + + if (!$this->isSafeFailureDetail($message)) { + return null; + } + + return $message . '.'; + } + + private function isSafeFailureDetail(string $message): bool + { + if (strlen($message) > 160) { + return false; + } + + $normalized = strtolower($message); + $unsafeFragments = [ + 'sqlstate', + 'select ', + 'insert ', + 'update ', + 'delete ', + 'create table', + 'drop table', + 'alter table', + 'exception', + 'stack trace', + ' in /', + ' at /', + '/var/www/', + '\\', + '<', + '>', + 'token_auth', + 'bearer ', + 'session', + 'permission denied', + 'call to ', + 'uncaught ', + ]; + + foreach ($unsafeFragments as $fragment) { + if (str_contains($normalized, $fragment)) { + return false; + } + } + + if (preg_match('/#\d+/', $message) === 1) { + return false; + } + + $safeSignals = [ + 'parameter', + 'missing', + 'invalid', + 'must be', + 'required', + 'expected', + 'unknown value', + 'not supported', + 'out of range', + ]; + + foreach ($safeSignals as $signal) { + if (str_contains($normalized, $signal)) { + return true; + } + } + + return false; + } +} diff --git a/Services/Api/CoreApiCallGateway.php b/Services/Api/CoreApiCallGateway.php new file mode 100644 index 0000000..0d8d3ea --- /dev/null +++ b/Services/Api/CoreApiCallGateway.php @@ -0,0 +1,62 @@ +requestProcessor = $requestProcessor; + } + + public function call(string $method, array $parameters): mixed + { + try { + if ($this->requestProcessor !== null) { + return ($this->requestProcessor)($method, $parameters, []); + } + + return Request::processRequest($method, $parameters, []); + } catch (\Throwable $e) { + if ($this->isNoAccessLikeFailure($e)) { + throw new AccessDeniedLikeException('No access to API method.', 0, $e); + } + + throw new CoreApiRequestException('Matomo API request failed.', 0, $e); + } + } + + private function isNoAccessLikeFailure(\Throwable $e): bool + { + if ($e instanceof AccessDeniedLikeException || $e instanceof NoAccessException) { + return true; + } + + $message = strtolower(trim((string) $e->getMessage())); + if ($message === '') { + return false; + } + + return str_contains($message, 'no access') + || str_contains($message, 'checkuserhasviewaccess') + || str_contains($message, 'view access'); + } +} diff --git a/config/config.php b/config/config.php index 4732f53..4066302 100644 --- a/config/config.php +++ b/config/config.php @@ -6,7 +6,9 @@ use Matomo\Dependencies\McpServer\Mcp\Server\Session\SessionStoreInterface; use Piwik\DI; +use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiCallQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiMethodSummaryQueryServiceInterface; +use Piwik\Plugins\McpServer\Contracts\Ports\Api\CoreApiCallGatewayInterface; use Piwik\Plugins\McpServer\Contracts\Ports\Dimensions\CoreCustomDimensionsGatewayInterface; use Piwik\Plugins\McpServer\Contracts\Ports\Dimensions\DimensionDetailQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\Dimensions\DimensionSummaryQueryServiceInterface; @@ -28,9 +30,12 @@ use Piwik\Plugins\McpServer\Contracts\Ports\Sites\SiteSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; use Piwik\Plugins\McpServer\McpServerFactory; +use Piwik\Plugins\McpServer\McpTools\ApiCall; use Piwik\Plugins\McpServer\McpTools\ApiGet; use Piwik\Plugins\McpServer\McpTools\ApiList; +use Piwik\Plugins\McpServer\Services\Api\ApiCallQueryService; use Piwik\Plugins\McpServer\Services\Api\ApiMethodSummaryQueryService; +use Piwik\Plugins\McpServer\Services\Api\CoreApiCallGateway; use Piwik\Plugins\McpServer\Services\Dimensions\CoreCustomDimensionsGateway; use Piwik\Plugins\McpServer\Services\Dimensions\DimensionDetailQueryService; use Piwik\Plugins\McpServer\Services\Dimensions\DimensionSummaryQueryService; @@ -56,7 +61,9 @@ use Piwik\Plugins\McpServer\Tasks; return [ + ApiCallQueryServiceInterface::class => DI::autowire(ApiCallQueryService::class), ApiMethodSummaryQueryServiceInterface::class => DI::autowire(ApiMethodSummaryQueryService::class), + CoreApiCallGatewayInterface::class => DI::autowire(CoreApiCallGateway::class), CoreApiModuleGatewayInterface::class => DI::autowire(CoreApiModuleGateway::class), CoreCustomDimensionsGatewayInterface::class => DI::autowire(CoreCustomDimensionsGateway::class), CoreGoalsGatewayInterface::class => DI::autowire(CoreGoalsGateway::class), @@ -82,6 +89,7 @@ DbSessionStore::class => DI::autowire(), McpServerFactory::class => DI::autowire(), PaginatedCollectionResponder::class => DI::autowire(), + ApiCall::class => DI::autowire(), ApiGet::class => DI::autowire(), ApiList::class => DI::autowire(), Tasks::class => DI::autowire(), diff --git a/docs/faq.md b/docs/faq.md index fd2206b..255b700 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -34,10 +34,10 @@ Configure raw Matomo API tool access in `config/config.ini.php`: raw_api_access_mode = none ``` -- `raw_api_access_mode`: Controls raw API discovery tool visibility for `matomo_api_list` and `matomo_api_get`. -- `none`: hides `matomo_api_list` and `matomo_api_get` (default). -- `read`: shows `matomo_api_list` and `matomo_api_get`, and currently returns only API actions with `get`/`is` prefix. This prefix-based filter is a temporary heuristic and may be replaced by a more accurate read/write classification in the future. -- `full`: shows `matomo_api_list` and `matomo_api_get`, and returns all discoverable API actions. +- `raw_api_access_mode`: Controls raw API tool visibility for `matomo_api_list`, `matomo_api_get`, and `matomo_api_call`. +- `none`: hides `matomo_api_list`, `matomo_api_get`, and `matomo_api_call` (default). +- `read`: shows the raw API tools and currently allows only API actions with `get`/`is` prefix. This prefix-based filter is a temporary heuristic and may be replaced by a more accurate read/write classification in the future. +- `full`: shows the raw API tools and allows all discoverable API actions. ## Enabling MCP diff --git a/tests/Integration/McpTools/ApiCallTest.php b/tests/Integration/McpTools/ApiCallTest.php new file mode 100644 index 0000000..819652d --- /dev/null +++ b/tests/Integration/McpTools/ApiCallTest.php @@ -0,0 +1,307 @@ +createSuperUser = true; + } + + public function setUp(): void + { + parent::setUp(); + + $this->idSite = Fixture::createWebsite( + '2015-01-01 00:00:00', + 0, + 'MCP Raw API Call Test Site', + 'https://raw-api-call.test', + ); + + $tracker = Fixture::getTracker( + $this->idSite, + '2015-01-03 12:00:00', + $defaultInit = true, + $useLocal = true, + ); + $tracker->setUrl('https://raw-api-call.test/page-a'); + Fixture::checkResponse($tracker->doTrackPageView('page-a')); + $tracker->setUrl('https://raw-api-call.test/page-b'); + Fixture::checkResponse($tracker->doTrackPageView('page-b')); + } + + public function testReadModeCallsKnownReadMethodByMethodSelector(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['method' => ' API.getMatomoVersion '], + __METHOD__, + ); + + $resolvedMethod = $content['resolvedMethod'] ?? null; + self::assertIsArray($resolvedMethod); + self::assertSame('API', $resolvedMethod['module'] ?? null); + self::assertSame('getMatomoVersion', $resolvedMethod['action'] ?? null); + self::assertSame('API.getMatomoVersion', $resolvedMethod['method'] ?? null); + self::assertArrayHasKey('result', $content); + self::assertIsString($content['result']); + self::assertNotSame('', $content['result']); + } + + public function testReadModeCallsKnownReadMethodByModuleAndActionSelector(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['module' => ' API ', 'action' => ' getMatomoVersion '], + __METHOD__, + ); + + $resolvedMethod = $content['resolvedMethod'] ?? null; + self::assertIsArray($resolvedMethod); + self::assertSame('API.getMatomoVersion', $resolvedMethod['method'] ?? null); + self::assertArrayHasKey('result', $content); + self::assertIsString($content['result']); + } + + public function testReadModeNormalizesDataTableResponse(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $baseline = Request::processRequest('Actions.getPageUrls', [ + 'idSite' => $this->idSite, + 'period' => 'day', + 'date' => '2015-01-03', + ], []); + self::assertInstanceOf(DataTableInterface::class, $baseline); + + $renderer = new Json(); + $renderer->setTable($baseline); + $expected = json_decode($renderer->render(), true, 512, JSON_THROW_ON_ERROR); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCall::TOOL_NAME, + [ + 'method' => 'Actions.getPageUrls', + 'parameters' => [ + 'idSite' => $this->idSite, + 'period' => 'day', + 'date' => '2015-01-03', + ], + ], + __METHOD__, + ); + + self::assertSame($expected, $content['result'] ?? null); + } + + public function testReadModeRejectsWriteOnlyMethod(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['method' => 'UsersManager.addUser'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testFullModeAttemptsMutatingMethodCall(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $result = McpTestHelper::callTool( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['method' => 'UsersManager.addUser'], + __METHOD__, + ); + + McpTestHelper::assertToolError($result); + $content = $result->content[0] ?? null; + self::assertInstanceOf(\Matomo\Dependencies\McpServer\Mcp\Schema\Content\TextContent::class, $content); + $errorText = $content->text; + self::assertIsString($errorText); + self::assertTrue( + $errorText === 'Matomo API request failed.' + || str_starts_with($errorText, 'Matomo API request failed: '), + ); + } + + public function testRejectsReservedParameterKeys(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCall::TOOL_NAME, + [ + 'method' => 'API.getMatomoVersion', + 'parameters' => ['format' => 'json'], + ], + "Unsupported parameters key 'format'.", + __METHOD__, + ); + } + + public function testRejectsMissingSelectorAtSchemaLevel(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $message = McpTestHelper::callToolExpectInvalidParams( + $server, + $sessionId, + ApiCall::TOOL_NAME, + [], + __METHOD__, + ); + + self::assertStringContainsString( + "Invalid parameters for tool '" . ApiCall::TOOL_NAME . "':", + $message->message, + ); + } + + public function testRejectsMixedSelectorStyleAtSchemaLevel(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeCallToolRequest( + ApiCall::TOOL_NAME, + ['method' => 'API.getMatomoVersion', 'module' => 'API'], + __METHOD__, + ); + + $response = McpTestHelper::postJson($server, $payload, ['Mcp-Session-Id' => $sessionId]); + $message = McpTestHelper::decodeError($response); + + self::assertSame(JsonRpcError::INVALID_PARAMS, $message->code); + self::assertStringContainsString( + "Invalid parameters for tool '" . ApiCall::TOOL_NAME . "':", + $message->message ?? '', + ); + } + + public function testSchemaDeclaresFlatSelectorsWithoutTopLevelCombinators(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeListToolsRequest(__METHOD__); + + $response = McpTestHelper::postJson($server, $payload, ['Mcp-Session-Id' => $sessionId]); + $message = McpTestHelper::decodeResponse($response); + $result = McpTestHelper::parseListTools($message); + + $apiCallTool = null; + foreach ($result->tools as $tool) { + if ($tool->name === ApiCall::TOOL_NAME) { + $apiCallTool = $tool; + break; + } + } + + self::assertNotNull($apiCallTool); + /** @var array $inputSchema */ + $inputSchema = $apiCallTool->inputSchema; + self::assertArrayNotHasKey('oneOf', $inputSchema); + self::assertArrayNotHasKey('allOf', $inputSchema); + self::assertArrayNotHasKey('anyOf', $inputSchema); + self::assertArrayHasKey('not', $inputSchema); + self::assertIsArray($inputSchema['not']); + } + + public function testNoneModeHidesAndRejectsToolCall(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; + self::assertNotContains(ApiCall::TOOL_NAME, $this->listToolNamesForCurrentConfig()); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeCallToolRequest( + ApiCall::TOOL_NAME, + ['method' => 'API.getMatomoVersion'], + __METHOD__, + ); + $response = McpTestHelper::postJson($server, $payload, ['Mcp-Session-Id' => $sessionId]); + $message = McpTestHelper::decodeError($response); + + self::assertSame(JsonRpcError::METHOD_NOT_FOUND, $message->code); + } + + /** + * @return list + */ + private function listToolNamesForCurrentConfig(): array + { + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeListToolsRequest(__METHOD__); + + $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) => $tool->name, $result->tools)); + } +} diff --git a/tests/Integration/McpToolsContractBaselineTest.php b/tests/Integration/McpToolsContractBaselineTest.php index b2d84f0..7982008 100644 --- a/tests/Integration/McpToolsContractBaselineTest.php +++ b/tests/Integration/McpToolsContractBaselineTest.php @@ -16,6 +16,7 @@ use Piwik\Plugins\API\API as ApiModuleApi; use Piwik\Plugins\CustomDimensions\API as CustomDimensionsApi; use Piwik\Plugins\Goals\API as GoalsApi; +use Piwik\Plugins\McpServer\McpTools\ApiCall; use Piwik\Plugins\McpServer\McpTools\ApiGet; use Piwik\Plugins\McpServer\McpTools\DimensionGet; use Piwik\Plugins\McpServer\McpTools\DimensionList; @@ -29,6 +30,7 @@ use Piwik\Plugins\McpServer\McpTools\SiteGet; use Piwik\Plugins\McpServer\McpTools\SiteList; use Piwik\Plugins\McpServer\McpTools\SiteSearch; +use Piwik\Plugins\McpServer\Schemas\Api\ApiCallToolOutputSchema; use Piwik\Plugins\McpServer\Schemas\Api\ApiMethodSummaryToolOutputSchema; use Piwik\Plugins\McpServer\Schemas\Dimensions\DimensionDetailToolOutputSchema; use Piwik\Plugins\McpServer\Schemas\Dimensions\DimensionSummaryToolOutputSchema; @@ -273,6 +275,23 @@ public function testApiGetSuccessShapeInReadMode(): void ContractShapeAssert::assertMatchesSchema(ApiMethodSummaryToolOutputSchema::ITEM, $content); } + public function testApiCallSuccessShapeInReadMode(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['method' => 'API.getMatomoVersion'], + __METHOD__, + ); + + ContractShapeAssert::assertMatchesSchema(ApiCallToolOutputSchema::ITEM, $content); + } + /** * @return array}> */ diff --git a/tests/Integration/McpToolsContractTest.php b/tests/Integration/McpToolsContractTest.php index 742ea18..aecc178 100644 --- a/tests/Integration/McpToolsContractTest.php +++ b/tests/Integration/McpToolsContractTest.php @@ -13,6 +13,7 @@ use Matomo\Dependencies\McpServer\Mcp\Schema\Tool; use Piwik\Config; +use Piwik\Plugins\McpServer\McpTools\ApiCall; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -148,6 +149,7 @@ public function testRawApiListToolIsHiddenWhenRawAccessModeIsNone(): void Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; $toolsByName = $this->listToolsByNameForCurrentConfig(); + self::assertArrayNotHasKey(ApiCall::TOOL_NAME, $toolsByName); self::assertArrayNotHasKey('matomo_api_get', $toolsByName); self::assertArrayNotHasKey('matomo_api_list', $toolsByName); } @@ -172,6 +174,14 @@ public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessM self::assertFalse($tool->annotations->destructiveHint); self::assertTrue($tool->annotations->idempotentHint); self::assertFalse($tool->annotations->openWorldHint); + + self::assertArrayHasKey(ApiCall::TOOL_NAME, $toolsByName); + $callTool = $toolsByName[ApiCall::TOOL_NAME]; + self::assertNotNull($callTool->annotations); + self::assertFalse($callTool->annotations->readOnlyHint); + self::assertFalse($callTool->annotations->destructiveHint); + self::assertFalse($callTool->annotations->idempotentHint); + self::assertFalse($callTool->annotations->openWorldHint); } public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessModeIsFull(): void @@ -194,6 +204,14 @@ public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessM self::assertFalse($tool->annotations->destructiveHint); self::assertTrue($tool->annotations->idempotentHint); self::assertFalse($tool->annotations->openWorldHint); + + self::assertArrayHasKey(ApiCall::TOOL_NAME, $toolsByName); + $callTool = $toolsByName[ApiCall::TOOL_NAME]; + self::assertNotNull($callTool->annotations); + self::assertFalse($callTool->annotations->readOnlyHint); + self::assertTrue($callTool->annotations->destructiveHint); + self::assertFalse($callTool->annotations->idempotentHint); + self::assertFalse($callTool->annotations->openWorldHint); } /** diff --git a/tests/Unit/McpServerFactoryTest.php b/tests/Unit/McpServerFactoryTest.php index 843b348..ff6a215 100644 --- a/tests/Unit/McpServerFactoryTest.php +++ b/tests/Unit/McpServerFactoryTest.php @@ -401,11 +401,13 @@ public function testRawApiListToolIsHiddenWhenRawAccessModeIsMissingOrNone(): vo { Config::getInstance()->McpServer = []; $toolsWhenMissing = $this->listToolNamesForCurrentConfig(); + self::assertNotContains('matomo_api_call', $toolsWhenMissing); self::assertNotContains('matomo_api_get', $toolsWhenMissing); self::assertNotContains('matomo_api_list', $toolsWhenMissing); Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; $toolsWhenNone = $this->listToolNamesForCurrentConfig(); + self::assertNotContains('matomo_api_call', $toolsWhenNone); self::assertNotContains('matomo_api_get', $toolsWhenNone); self::assertNotContains('matomo_api_list', $toolsWhenNone); } @@ -414,11 +416,13 @@ public function testRawApiListToolIsVisibleWhenRawAccessModeIsReadOrFull(): void { Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; $toolsWhenRead = $this->listToolNamesForCurrentConfig(); + self::assertContains('matomo_api_call', $toolsWhenRead); self::assertContains('matomo_api_get', $toolsWhenRead); self::assertContains('matomo_api_list', $toolsWhenRead); Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; $toolsWhenFull = $this->listToolNamesForCurrentConfig(); + self::assertContains('matomo_api_call', $toolsWhenFull); self::assertContains('matomo_api_get', $toolsWhenFull); self::assertContains('matomo_api_list', $toolsWhenFull); } @@ -448,6 +452,31 @@ public function testRawApiGetToolHasFullAnnotationsWhenVisible(): void self::assertFalse($toolWhenFull->annotations->openWorldHint); } + public function testRawApiCallToolHasFullAnnotationsWhenVisible(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + $toolsWhenRead = $this->listToolsByNameForCurrentConfig(); + + self::assertArrayHasKey('matomo_api_call', $toolsWhenRead); + $toolWhenRead = $toolsWhenRead['matomo_api_call']; + self::assertNotNull($toolWhenRead->annotations); + self::assertFalse($toolWhenRead->annotations->readOnlyHint); + self::assertFalse($toolWhenRead->annotations->destructiveHint); + self::assertFalse($toolWhenRead->annotations->idempotentHint); + self::assertFalse($toolWhenRead->annotations->openWorldHint); + + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + $toolsWhenFull = $this->listToolsByNameForCurrentConfig(); + + self::assertArrayHasKey('matomo_api_call', $toolsWhenFull); + $toolWhenFull = $toolsWhenFull['matomo_api_call']; + self::assertNotNull($toolWhenFull->annotations); + self::assertFalse($toolWhenFull->annotations->readOnlyHint); + self::assertTrue($toolWhenFull->annotations->destructiveHint); + self::assertFalse($toolWhenFull->annotations->idempotentHint); + self::assertFalse($toolWhenFull->annotations->openWorldHint); + } + /** * @return list */ diff --git a/tests/Unit/McpTools/ApiCallTest.php b/tests/Unit/McpTools/ApiCallTest.php new file mode 100644 index 0000000..3ebf933 --- /dev/null +++ b/tests/Unit/McpTools/ApiCallTest.php @@ -0,0 +1,151 @@ +|null */ + private ?array $originalMcpServerConfig = null; + + public function setUp(): void + { + parent::setUp(); + + $originalConfig = Config::getInstance()->McpServer ?? null; + $this->originalMcpServerConfig = is_array($originalConfig) ? $originalConfig : null; + } + + public function tearDown(): void + { + Config::getInstance()->McpServer = $this->originalMcpServerConfig; + + parent::tearDown(); + } + + public function testCallUsesMethodSelector(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + $captured = new stdClass(); + $captured->values = []; + + $tool = new ApiCall( + new class ($captured) implements ApiCallQueryServiceInterface { + public function __construct(private stdClass $captured) + { + } + + public function callApi( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ?array $parameters = null, + ): ApiCallRecord { + $this->captured->values = [ + 'accessMode' => $accessMode, + 'method' => $method, + 'module' => $module, + 'action' => $action, + 'parameters' => $parameters, + ]; + + return new ApiCallRecord( + '6.0.0', + new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), + ); + } + }, + ); + + $actual = $tool->call(method: ' API.getMatomoVersion '); + + self::assertSame([ + 'result' => '6.0.0', + 'resolvedMethod' => [ + 'module' => 'API', + 'action' => 'getMatomoVersion', + 'method' => 'API.getMatomoVersion', + 'parameters' => [], + ], + ], $actual); + /** @var array $capturedValues */ + $capturedValues = $captured->values; + self::assertSame('read', $capturedValues['accessMode']); + self::assertSame(' API.getMatomoVersion ', $capturedValues['method']); + self::assertNull($capturedValues['module']); + self::assertNull($capturedValues['action']); + self::assertNull($capturedValues['parameters']); + } + + public function testCallUsesSplitSelectorAndParameters(): void + { + Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + $captured = new stdClass(); + $captured->values = []; + + $tool = new ApiCall( + new class ($captured) implements ApiCallQueryServiceInterface { + public function __construct(private stdClass $captured) + { + } + + public function callApi( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ?array $parameters = null, + ): ApiCallRecord { + $this->captured->values = [ + 'accessMode' => $accessMode, + 'method' => $method, + 'module' => $module, + 'action' => $action, + 'parameters' => $parameters, + ]; + + return new ApiCallRecord( + ['success' => true], + new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), + ); + } + }, + ); + + $actual = $tool->call( + module: ' UsersManager ', + action: ' addUser ', + parameters: ['userLogin' => 'alice'], + ); + + self::assertSame(['success' => true], $actual['result']); + /** @var array $capturedValues */ + $capturedValues = $captured->values; + self::assertSame('full', $capturedValues['accessMode']); + self::assertNull($capturedValues['method']); + self::assertSame(' UsersManager ', $capturedValues['module']); + self::assertSame(' addUser ', $capturedValues['action']); + self::assertSame(['userLogin' => 'alice'], $capturedValues['parameters']); + } +} diff --git a/tests/Unit/Services/ApiCallQueryServiceTest.php b/tests/Unit/Services/ApiCallQueryServiceTest.php new file mode 100644 index 0000000..6969500 --- /dev/null +++ b/tests/Unit/Services/ApiCallQueryServiceTest.php @@ -0,0 +1,388 @@ +values = []; + + $service = new ApiCallQueryService( + new class ($captured) implements ApiMethodSummaryQueryServiceInterface { + public function __construct(private stdClass $captured) + { + } + + public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array + { + return []; + } + + public function getApiMethodSummaryBySelector( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ApiMethodSummaryRecord { + $this->captured->values = [ + 'accessMode' => $accessMode, + 'method' => $method, + 'module' => $module, + 'action' => $action, + ]; + + return new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []); + } + }, + new class () implements CoreApiCallGatewayInterface { + public function call(string $method, array $parameters): mixed + { + TestCase::assertSame('API.getMatomoVersion', $method); + TestCase::assertSame([], $parameters); + + return '6.0.0'; + } + }, + ); + + $record = $service->callApi('read', 'API.getMatomoVersion'); + + self::assertSame('6.0.0', $record->result); + self::assertSame('API.getMatomoVersion', $record->resolvedMethod->method); + /** @var array $capturedValues */ + $capturedValues = $captured->values; + self::assertSame([ + 'accessMode' => 'read', + 'method' => 'API.getMatomoVersion', + 'module' => null, + 'action' => null, + ], $capturedValues); + } + + public function testCallApiPassesParametersAndNormalizesObjects(): void + { + $service = new ApiCallQueryService( + $this->createQueryServiceStub(new ApiMethodSummaryRecord('API', 'getSettings', 'API.getSettings', [])), + new class () implements CoreApiCallGatewayInterface { + public function call(string $method, array $parameters): mixed + { + TestCase::assertSame(['idSite' => 3], $parameters); + + return (object) ['site' => (object) ['id' => 3, 'name' => 'Demo']]; + } + }, + ); + + $record = $service->callApi('read', 'API.getSettings', parameters: ['idSite' => 3]); + + self::assertSame(['site' => ['id' => 3, 'name' => 'Demo']], $record->result); + } + + public function testCallApiNormalizesDataTableResultsViaJsonRenderer(): void + { + $table = new DataTable(); + $table->addRow(new Row([ + Row::COLUMNS => [ + 'label' => '/pricing', + 'nb_visits' => 4, + ], + ])); + + $service = new ApiCallQueryService( + $this->createQueryServiceStub( + new ApiMethodSummaryRecord('Actions', 'getPageUrls', 'Actions.getPageUrls', []), + ), + new class ($table) implements CoreApiCallGatewayInterface { + public function __construct(private DataTable $table) + { + } + + public function call(string $method, array $parameters): mixed + { + return $this->table; + } + }, + ); + + $record = $service->callApi('read', 'Actions.getPageUrls'); + + self::assertSame([ + [ + 'label' => '/pricing', + 'nb_visits' => 4, + ], + ], $record->result); + } + + public function testCallApiNormalizesNestedDataTableMapResultsViaJsonRenderer(): void + { + $first = new DataTable(); + $first->addRow(new Row([ + Row::COLUMNS => [ + 'label' => 'first', + 'nb_visits' => 1, + ], + ])); + + $second = new DataTable(); + $second->addRow(new Row([ + Row::COLUMNS => [ + 'label' => 'second', + 'nb_visits' => 2, + ], + ])); + + $map = new Map(); + $map->addTable($first, '2024-01-01'); + $map->addTable($second, '2024-01-02'); + + $service = new ApiCallQueryService( + $this->createQueryServiceStub( + new ApiMethodSummaryRecord('Live', 'getLastVisitDetails', 'Live.getLastVisitDetails', []), + ), + new class ($map) implements CoreApiCallGatewayInterface { + public function __construct(private Map $map) + { + } + + public function call(string $method, array $parameters): mixed + { + return ['report' => $this->map]; + } + }, + ); + + $record = $service->callApi('read', 'Live.getLastVisitDetails'); + + self::assertSame([ + 'report' => [ + '2024-01-01' => [ + [ + 'label' => 'first', + 'nb_visits' => 1, + ], + ], + '2024-01-02' => [ + [ + 'label' => 'second', + 'nb_visits' => 2, + ], + ], + ], + ], $record->result); + } + + public function testCallApiRejectsReservedParameterKeys(): void + { + $service = new ApiCallQueryService( + $this->createQueryServiceStub( + new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), + ), + new class () implements CoreApiCallGatewayInterface { + public function call(string $method, array $parameters): mixed + { + throw new \BadMethodCallException('Should not be called.'); + } + }, + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage("Unsupported parameters key 'format'."); + + $service->callApi('read', 'API.getMatomoVersion', parameters: ['format' => 'json']); + } + + public function testCallApiMapsAccessDeniedFailures(): void + { + $service = new ApiCallQueryService( + $this->createQueryServiceStub( + new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), + ), + new class () implements CoreApiCallGatewayInterface { + public function call(string $method, array $parameters): mixed + { + throw new AccessDeniedLikeException('denied'); + } + }, + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('No access to API method.'); + + $service->callApi('read', 'API.getMatomoVersion'); + } + + public function testCallApiMapsUpstreamFailures(): void + { + $service = new ApiCallQueryService( + $this->createQueryServiceStub( + new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), + ), + new class () implements CoreApiCallGatewayInterface { + public function call(string $method, array $parameters): mixed + { + throw new CoreApiRequestException('failed'); + } + }, + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Matomo API request failed.'); + + $service->callApi('full', 'UsersManager.addUser'); + } + + public function testCallApiSurfacesSanitizedValidationFailureDetail(): void + { + $service = new ApiCallQueryService( + $this->createQueryServiceStub( + new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), + ), + new class () implements CoreApiCallGatewayInterface { + public function call(string $method, array $parameters): mixed + { + throw new CoreApiRequestException( + 'failed', + 0, + new \RuntimeException("Parameter 'userLogin' missing or invalid."), + ); + } + }, + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage("Matomo API request failed: Parameter 'userLogin' missing or invalid."); + + $service->callApi('full', 'UsersManager.addUser'); + } + + public function testCallApiKeepsGenericFailureForUnsafeUpstreamDetail(): void + { + $service = new ApiCallQueryService( + $this->createQueryServiceStub( + new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), + ), + new class () implements CoreApiCallGatewayInterface { + public function call(string $method, array $parameters): mixed + { + throw new CoreApiRequestException( + 'failed', + 0, + new \RuntimeException('SQLSTATE[42S02]: Base table or view not found'), + ); + } + }, + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Matomo API request failed.'); + + $service->callApi('full', 'UsersManager.addUser'); + } + + public function testCallApiKeepsGenericFailureWhenNoSafeDetailExists(): void + { + $service = new ApiCallQueryService( + $this->createQueryServiceStub( + new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), + ), + new class () implements CoreApiCallGatewayInterface { + public function call(string $method, array $parameters): mixed + { + throw new CoreApiRequestException('failed'); + } + }, + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Matomo API request failed.'); + + $service->callApi('full', 'UsersManager.addUser'); + } + + public function testCallApiRejectsInvalidResponse(): void + { + $resource = fopen('php://memory', 'rb'); + self::assertIsResource($resource); + + try { + $service = new ApiCallQueryService( + $this->createQueryServiceStub( + new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), + ), + new class ($resource) implements CoreApiCallGatewayInterface { + private mixed $resource; + + public function __construct(mixed $resource) + { + $this->resource = $resource; + } + + public function call(string $method, array $parameters): mixed + { + return ['stream' => $this->resource]; + } + }, + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('API response is invalid.'); + + $service->callApi('read', 'API.getMatomoVersion'); + } finally { + fclose($resource); + } + } + + private function createQueryServiceStub(ApiMethodSummaryRecord $record): ApiMethodSummaryQueryServiceInterface + { + return new class ($record) implements ApiMethodSummaryQueryServiceInterface { + public function __construct(private ApiMethodSummaryRecord $record) + { + } + + public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array + { + return []; + } + + public function getApiMethodSummaryBySelector( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ApiMethodSummaryRecord { + return $this->record; + } + }; + } +} diff --git a/tests/Unit/Services/CoreApiCallGatewayTest.php b/tests/Unit/Services/CoreApiCallGatewayTest.php new file mode 100644 index 0000000..189b509 --- /dev/null +++ b/tests/Unit/Services/CoreApiCallGatewayTest.php @@ -0,0 +1,68 @@ + 3], $parameters); + TestCase::assertSame([], $extra); + + return ['version' => '6.0.0']; + }, + ); + + self::assertSame(['version' => '6.0.0'], $gateway->call('API.getMatomoVersion', ['idSite' => 3])); + } + + public function testCallMapsNoAccessException(): void + { + $gateway = new CoreApiCallGateway( + static function (string $method, array $parameters, array $extra): mixed { + throw new NoAccessException('denied'); + }, + ); + + $this->expectException(AccessDeniedLikeException::class); + $this->expectExceptionMessage('No access to API method.'); + + $gateway->call('API.getMatomoVersion', []); + } + + public function testCallMapsUnexpectedFailures(): void + { + $gateway = new CoreApiCallGateway( + static function (string $method, array $parameters, array $extra): mixed { + throw new \RuntimeException('timeout'); + }, + ); + + $this->expectException(CoreApiRequestException::class); + $this->expectExceptionMessage('Matomo API request failed.'); + + $gateway->call('API.getMatomoVersion', []); + } +} From 4215fb0b88935120072f2b979da98be27f60b179 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Wed, 8 Apr 2026 16:33:18 +0200 Subject: [PATCH 04/15] Centralize access-like error detection --- Services/Api/CoreApiCallGateway.php | 20 +------ .../CoreCustomDimensionsGateway.php | 19 +------ Services/Goals/CoreGoalsGateway.php | 19 +------ .../Reports/ReportProcessedQueryService.php | 22 ++------ .../Reports/ReportSummaryQueryService.php | 15 +----- .../Segments/CoreSegmentEditorGateway.php | 19 +------ .../Segments/SegmentDetailQueryService.php | 15 +----- Services/Sites/CoreSitesManagerGateway.php | 19 +------ Services/Sites/SiteDetailQueryService.php | 13 +++-- Support/Errors/NoAccessLikeErrorDetector.php | 31 +++++++++++ .../Unit/Services/CoreApiCallGatewayTest.php | 14 +++++ .../CoreCustomDimensionsGatewayTest.php | 16 ++++++ .../Services/Goals/CoreGoalsGatewayTest.php | 14 +++++ .../ReportProcessedQueryServiceTest.php | 35 ++++++++++++ .../Segments/CoreSegmentEditorGatewayTest.php | 14 +++++ .../SegmentDetailQueryServiceTest.php | 21 ++++++++ .../Sites/CoreSitesManagerGatewayTest.php | 14 +++++ .../Sites/SiteDetailQueryServiceTest.php | 34 ++++++++++++ .../Errors/NoAccessLikeErrorDetectorTest.php | 53 +++++++++++++++++++ 19 files changed, 269 insertions(+), 138 deletions(-) create mode 100644 Support/Errors/NoAccessLikeErrorDetector.php create mode 100644 tests/Unit/Support/Errors/NoAccessLikeErrorDetectorTest.php diff --git a/Services/Api/CoreApiCallGateway.php b/Services/Api/CoreApiCallGateway.php index 0d8d3ea..772ec61 100644 --- a/Services/Api/CoreApiCallGateway.php +++ b/Services/Api/CoreApiCallGateway.php @@ -12,10 +12,10 @@ namespace Piwik\Plugins\McpServer\Services\Api; use Piwik\API\Request; -use Piwik\NoAccessException; use Piwik\Plugins\McpServer\Contracts\Ports\Api\CoreApiCallGatewayInterface; use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; use Piwik\Plugins\McpServer\Support\Errors\CoreApiRequestException; +use Piwik\Plugins\McpServer\Support\Errors\NoAccessLikeErrorDetector; final class CoreApiCallGateway implements CoreApiCallGatewayInterface { @@ -36,27 +36,11 @@ public function call(string $method, array $parameters): mixed return Request::processRequest($method, $parameters, []); } catch (\Throwable $e) { - if ($this->isNoAccessLikeFailure($e)) { + if (NoAccessLikeErrorDetector::isDetected($e)) { throw new AccessDeniedLikeException('No access to API method.', 0, $e); } throw new CoreApiRequestException('Matomo API request failed.', 0, $e); } } - - private function isNoAccessLikeFailure(\Throwable $e): bool - { - if ($e instanceof AccessDeniedLikeException || $e instanceof NoAccessException) { - return true; - } - - $message = strtolower(trim((string) $e->getMessage())); - if ($message === '') { - return false; - } - - return str_contains($message, 'no access') - || str_contains($message, 'checkuserhasviewaccess') - || str_contains($message, 'view access'); - } } diff --git a/Services/Dimensions/CoreCustomDimensionsGateway.php b/Services/Dimensions/CoreCustomDimensionsGateway.php index ecdceec..aa6f206 100644 --- a/Services/Dimensions/CoreCustomDimensionsGateway.php +++ b/Services/Dimensions/CoreCustomDimensionsGateway.php @@ -15,6 +15,7 @@ use Piwik\API\Request; use Piwik\Plugins\McpServer\Contracts\Ports\Dimensions\CoreCustomDimensionsGatewayInterface; use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; +use Piwik\Plugins\McpServer\Support\Errors\NoAccessLikeErrorDetector; use Piwik\Plugins\McpServer\Support\Normalization\ToolDataNormalizer; final class CoreCustomDimensionsGateway implements CoreCustomDimensionsGatewayInterface @@ -26,7 +27,7 @@ public function getConfiguredCustomDimensions(int $idSite): array 'idSite' => $idSite, ], []); } catch (\Throwable $e) { - if ($this->isNoAccessLikeFailure($e)) { + if (NoAccessLikeErrorDetector::isDetected($e)) { throw new AccessDeniedLikeException('No access to this resource.', 0, $e); } @@ -56,20 +57,4 @@ private function normalizeRows(mixed $rows, string $invalidDataMessage): array return $normalized; } - - private function isNoAccessLikeFailure(\Throwable $e): bool - { - if ($e instanceof AccessDeniedLikeException || $e instanceof \Piwik\NoAccessException) { - return true; - } - - $message = strtolower(trim((string) $e->getMessage())); - if ($message === '') { - return false; - } - - return str_contains($message, 'no access') - || str_contains($message, 'checkuserhasviewaccess') - || str_contains($message, 'view access'); - } } diff --git a/Services/Goals/CoreGoalsGateway.php b/Services/Goals/CoreGoalsGateway.php index b1d42c6..81dda4b 100644 --- a/Services/Goals/CoreGoalsGateway.php +++ b/Services/Goals/CoreGoalsGateway.php @@ -15,6 +15,7 @@ use Piwik\API\Request; use Piwik\Plugins\McpServer\Contracts\Ports\Goals\CoreGoalsGatewayInterface; use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; +use Piwik\Plugins\McpServer\Support\Errors\NoAccessLikeErrorDetector; use Piwik\Plugins\McpServer\Support\Normalization\ToolDataNormalizer; final class CoreGoalsGateway implements CoreGoalsGatewayInterface @@ -59,7 +60,7 @@ private function processRequest(string $method, array $paramOverride): mixed return Request::processRequest($method, $paramOverride, []); } catch (\Throwable $e) { - if ($this->isNoAccessLikeFailure($e)) { + if (NoAccessLikeErrorDetector::isDetected($e)) { throw new AccessDeniedLikeException('No access to this resource.', 0, $e); } @@ -87,20 +88,4 @@ private function normalizeRows(mixed $rows, string $invalidDataMessage): array return $normalized; } - - private function isNoAccessLikeFailure(\Throwable $e): bool - { - if ($e instanceof AccessDeniedLikeException || $e instanceof \Piwik\NoAccessException) { - return true; - } - - $message = strtolower(trim((string) $e->getMessage())); - if ($message === '') { - return false; - } - - return str_contains($message, 'no access') - || str_contains($message, 'checkuserhasviewaccess') - || str_contains($message, 'view access'); - } } diff --git a/Services/Reports/ReportProcessedQueryService.php b/Services/Reports/ReportProcessedQueryService.php index 9ed3d06..df0fd35 100644 --- a/Services/Reports/ReportProcessedQueryService.php +++ b/Services/Reports/ReportProcessedQueryService.php @@ -30,6 +30,7 @@ use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; use Piwik\Plugins\McpServer\Support\Errors\CoreApiRequestException; use Piwik\Plugins\McpServer\Support\Errors\InfrastructureDataException; +use Piwik\Plugins\McpServer\Support\Errors\NoAccessLikeErrorDetector; use Piwik\Plugins\McpServer\Support\Errors\ToolErrorMapper; use Piwik\Plugins\McpServer\Support\Normalization\ToolDataNormalizer; use Piwik\Plugins\McpServer\Support\Reports\GoalMetricsMode; @@ -583,7 +584,7 @@ private function callProcessedReport( $rootCause, 'Report not found.', 'Report retrieval failed.', - fn(\Throwable $error): bool => $this->isNoAccessLikeFailure($error) + static fn(\Throwable $error): bool => NoAccessLikeErrorDetector::isDetected($error) && ViewAccessFallback::shouldReturnEmptyOnNoAccessFallback() ); } catch (ToolCallException $e) { @@ -593,7 +594,7 @@ private function callProcessedReport( $e, 'Report not found.', 'Report retrieval failed.', - fn(\Throwable $error): bool => $this->isNoAccessLikeFailure($error) + static fn(\Throwable $error): bool => NoAccessLikeErrorDetector::isDetected($error) && ViewAccessFallback::shouldReturnEmptyOnNoAccessFallback() ); } @@ -805,23 +806,6 @@ private function invokeProcessedReport( $idSubtable, ); } - - private function isNoAccessLikeFailure(\Throwable $e): bool - { - if ($e instanceof NoAccessException) { - return true; - } - - $message = strtolower(trim((string) $e->getMessage())); - if ($message === '') { - return false; - } - - return str_contains($message, 'no access') - || str_contains($message, 'checkuserhasviewaccess') - || str_contains($message, 'view access'); - } - private function isStrictSegmentRestrictionLikeFailure(\Throwable $e): bool { $current = $e; diff --git a/Services/Reports/ReportSummaryQueryService.php b/Services/Reports/ReportSummaryQueryService.php index f669770..b8e9db4 100644 --- a/Services/Reports/ReportSummaryQueryService.php +++ b/Services/Reports/ReportSummaryQueryService.php @@ -15,6 +15,7 @@ use Piwik\API\Request; use Piwik\Plugins\McpServer\Contracts\Ports\Reports\ReportSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Reports\ReportSummaryRecord; +use Piwik\Plugins\McpServer\Support\Errors\NoAccessLikeErrorDetector; use Piwik\Plugins\McpServer\Support\Errors\ToolErrorMapper; use Piwik\Plugins\McpServer\Support\Normalization\ToolDataNormalizer; @@ -42,7 +43,7 @@ public function getReportSummariesForSite(int $idSite): array if ( ToolErrorMapper::shouldReturnEmptyListFor( $e, - fn(\Throwable $error): bool => $this->isNoAccessLikeFailure($error) + static fn(\Throwable $error): bool => NoAccessLikeErrorDetector::isDetected($error) ) ) { return []; @@ -139,16 +140,4 @@ private function normalizeOptionalStringField(array $report, string $field, stri return $value; } - - private function isNoAccessLikeFailure(\Throwable $e): bool - { - $message = strtolower(trim((string) $e->getMessage())); - if ($message === '') { - return false; - } - - return str_contains($message, 'no access') - || str_contains($message, 'checkuserhasviewaccess') - || str_contains($message, 'view access'); - } } diff --git a/Services/Segments/CoreSegmentEditorGateway.php b/Services/Segments/CoreSegmentEditorGateway.php index 35657d3..77801e0 100644 --- a/Services/Segments/CoreSegmentEditorGateway.php +++ b/Services/Segments/CoreSegmentEditorGateway.php @@ -15,6 +15,7 @@ use Piwik\API\Request; use Piwik\Plugins\McpServer\Contracts\Ports\Segments\CoreSegmentEditorGatewayInterface; use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; +use Piwik\Plugins\McpServer\Support\Errors\NoAccessLikeErrorDetector; use Piwik\Plugins\McpServer\Support\Normalization\ToolDataNormalizer; final class CoreSegmentEditorGateway implements CoreSegmentEditorGatewayInterface @@ -48,7 +49,7 @@ private function processRequest(string $method, array $paramOverride): mixed return Request::processRequest($method, $paramOverride, []); } catch (\Throwable $e) { - if ($this->isNoAccessLikeFailure($e)) { + if (NoAccessLikeErrorDetector::isDetected($e)) { throw new AccessDeniedLikeException('No access to this resource.', 0, $e); } @@ -56,22 +57,6 @@ private function processRequest(string $method, array $paramOverride): mixed } } - private function isNoAccessLikeFailure(\Throwable $e): bool - { - if ($e instanceof AccessDeniedLikeException || $e instanceof \Piwik\NoAccessException) { - return true; - } - - $message = strtolower(trim((string) $e->getMessage())); - if ($message === '') { - return false; - } - - return str_contains($message, 'no access') - || str_contains($message, 'checkuserhasviewaccess') - || str_contains($message, 'view access'); - } - /** * @return list> */ diff --git a/Services/Segments/SegmentDetailQueryService.php b/Services/Segments/SegmentDetailQueryService.php index b9e3e05..ca8375e 100644 --- a/Services/Segments/SegmentDetailQueryService.php +++ b/Services/Segments/SegmentDetailQueryService.php @@ -17,6 +17,7 @@ use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; use Piwik\Plugins\McpServer\Contracts\Records\Segments\SegmentDetailRecord; use Piwik\Plugins\McpServer\Support\Access\ViewAccessFallback; +use Piwik\Plugins\McpServer\Support\Errors\NoAccessLikeErrorDetector; use Piwik\Plugins\McpServer\Support\Errors\ToolErrorMapper; use Piwik\Plugins\McpServer\Support\Normalization\ToolDataNormalizer; @@ -44,7 +45,7 @@ public function getSegmentDetailsForSite(int $idSite): array $e, 'Segment not found.', 'Segment retrieval failed.', - fn(\Throwable $error): bool => $this->isNoAccessLikeFailure($error) + static fn(\Throwable $error): bool => NoAccessLikeErrorDetector::isDetected($error) || ViewAccessFallback::shouldReturnEmptyOnNoAccessFallback() ); } @@ -131,16 +132,4 @@ public function normalizeSegmentDetailRows( return $result; } - - private function isNoAccessLikeFailure(\Throwable $e): bool - { - $message = strtolower(trim((string) $e->getMessage())); - if ($message === '') { - return false; - } - - return str_contains($message, 'no access') - || str_contains($message, 'checkuserhasviewaccess') - || str_contains($message, 'view access'); - } } diff --git a/Services/Sites/CoreSitesManagerGateway.php b/Services/Sites/CoreSitesManagerGateway.php index c99d988..c2b4b53 100644 --- a/Services/Sites/CoreSitesManagerGateway.php +++ b/Services/Sites/CoreSitesManagerGateway.php @@ -15,6 +15,7 @@ use Piwik\API\Request; use Piwik\Plugins\McpServer\Contracts\Ports\Sites\CoreSitesManagerGatewayInterface; use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; +use Piwik\Plugins\McpServer\Support\Errors\NoAccessLikeErrorDetector; use Piwik\Plugins\McpServer\Support\Normalization\ToolDataNormalizer; final class CoreSitesManagerGateway implements CoreSitesManagerGatewayInterface @@ -59,7 +60,7 @@ private function processRequest(string $method, array $paramOverride): mixed return Request::processRequest($method, $paramOverride, []); } catch (\Throwable $e) { - if ($this->isNoAccessLikeFailure($e)) { + if (NoAccessLikeErrorDetector::isDetected($e)) { throw new AccessDeniedLikeException('No access to this resource.', 0, $e); } @@ -87,20 +88,4 @@ private function normalizeRows(mixed $rows, string $invalidDataMessage): array return $normalized; } - - private function isNoAccessLikeFailure(\Throwable $e): bool - { - if ($e instanceof AccessDeniedLikeException || $e instanceof \Piwik\NoAccessException) { - return true; - } - - $message = strtolower(trim((string) $e->getMessage())); - if ($message === '') { - return false; - } - - return str_contains($message, 'no access') - || str_contains($message, 'checkuserhasviewaccess') - || str_contains($message, 'view access'); - } } diff --git a/Services/Sites/SiteDetailQueryService.php b/Services/Sites/SiteDetailQueryService.php index 630717b..7428358 100644 --- a/Services/Sites/SiteDetailQueryService.php +++ b/Services/Sites/SiteDetailQueryService.php @@ -15,6 +15,7 @@ use Piwik\Plugins\McpServer\Contracts\Ports\Sites\CoreSitesManagerGatewayInterface; use Piwik\Plugins\McpServer\Contracts\Ports\Sites\SiteDetailQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Sites\SiteDetailRecord; +use Piwik\Plugins\McpServer\Support\Errors\NoAccessLikeErrorDetector; use Piwik\Plugins\McpServer\Support\Errors\ToolErrorMapper; use Piwik\Plugins\McpServer\Support\Normalization\ToolDataNormalizer; @@ -72,15 +73,13 @@ private function isNotFoundOrNoAccessLikeFailure(\Throwable $e): bool return true; } - $message = strtolower(trim((string) $e->getMessage())); - if ($message === '') { - return false; + if (NoAccessLikeErrorDetector::isDetected($e)) { + return true; } - return str_contains($message, 'no access') - || str_contains($message, 'checkuserhasviewaccess') - || str_contains($message, 'view access') - || str_contains($message, 'does not exist') + $message = strtolower(trim((string) $e->getMessage())); + + return str_contains($message, 'does not exist') || str_contains($message, 'website') || str_contains($message, 'not found'); } diff --git a/Support/Errors/NoAccessLikeErrorDetector.php b/Support/Errors/NoAccessLikeErrorDetector.php new file mode 100644 index 0000000..1d49f91 --- /dev/null +++ b/Support/Errors/NoAccessLikeErrorDetector.php @@ -0,0 +1,31 @@ +getMessage())); + if ($message === '') { + return false; + } + + return str_contains($message, 'no access') + || str_contains($message, 'checkuserhasviewaccess') + || str_contains($message, 'view access'); + } +} diff --git a/tests/Unit/Services/CoreApiCallGatewayTest.php b/tests/Unit/Services/CoreApiCallGatewayTest.php index 189b509..fd6d469 100644 --- a/tests/Unit/Services/CoreApiCallGatewayTest.php +++ b/tests/Unit/Services/CoreApiCallGatewayTest.php @@ -52,6 +52,20 @@ static function (string $method, array $parameters, array $extra): mixed { $gateway->call('API.getMatomoVersion', []); } + public function testCallMapsMessageBasedNoAccessLikeFailure(): void + { + $gateway = new CoreApiCallGateway( + static function (string $method, array $parameters, array $extra): mixed { + throw new \RuntimeException('CheckUserHasViewAccess failed'); + }, + ); + + $this->expectException(AccessDeniedLikeException::class); + $this->expectExceptionMessage('No access to API method.'); + + $gateway->call('API.getMatomoVersion', []); + } + public function testCallMapsUnexpectedFailures(): void { $gateway = new CoreApiCallGateway( diff --git a/tests/Unit/Services/Dimensions/CoreCustomDimensionsGatewayTest.php b/tests/Unit/Services/Dimensions/CoreCustomDimensionsGatewayTest.php index 44ca475..5edfa95 100644 --- a/tests/Unit/Services/Dimensions/CoreCustomDimensionsGatewayTest.php +++ b/tests/Unit/Services/Dimensions/CoreCustomDimensionsGatewayTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase; use Piwik\Plugins\CustomDimensions\API as CustomDimensionsApi; use Piwik\Plugins\McpServer\Services\Dimensions\CoreCustomDimensionsGateway; +use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; /** * @group McpServer @@ -80,4 +81,19 @@ public function testGetConfiguredCustomDimensionsRejectsInvalidRowPayload(): voi $this->expectExceptionMessage('Custom dimensions data is invalid.'); $gateway->getConfiguredCustomDimensions(7); } + + public function testGetConfiguredCustomDimensionsMapsMessageBasedAccessFailure(): void + { + $api = $this->createMock(CustomDimensionsApi::class); + $api->expects(self::once()) + ->method('getConfiguredCustomDimensions') + ->willThrowException(new \RuntimeException('CheckUserHasViewAccess failed')); + CustomDimensionsApi::setSingletonInstance($api); + + $gateway = new CoreCustomDimensionsGateway(); + + $this->expectException(AccessDeniedLikeException::class); + $this->expectExceptionMessage('No access to this resource.'); + $gateway->getConfiguredCustomDimensions(7); + } } diff --git a/tests/Unit/Services/Goals/CoreGoalsGatewayTest.php b/tests/Unit/Services/Goals/CoreGoalsGatewayTest.php index 2ce5bfc..6d417e2 100644 --- a/tests/Unit/Services/Goals/CoreGoalsGatewayTest.php +++ b/tests/Unit/Services/Goals/CoreGoalsGatewayTest.php @@ -14,6 +14,7 @@ use Matomo\Dependencies\McpServer\Mcp\Exception\ToolCallException; use PHPUnit\Framework\TestCase; use Piwik\Plugins\McpServer\Services\Goals\CoreGoalsGateway; +use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; /** * @group McpServer @@ -99,4 +100,17 @@ static function (string $method, array $paramOverride, array $defaultRequest): a $this->expectExceptionMessage('Goal data is invalid.'); $gateway->getGoal(5, 3); } + + public function testGetGoalMapsMessageBasedAccessFailure(): void + { + $gateway = new CoreGoalsGateway( + static function (string $method, array $paramOverride, array $defaultRequest): mixed { + throw new \RuntimeException('Missing view access permission'); + }, + ); + + $this->expectException(AccessDeniedLikeException::class); + $this->expectExceptionMessage('No access to this resource.'); + $gateway->getGoal(5, 3); + } } diff --git a/tests/Unit/Services/Reports/ReportProcessedQueryServiceTest.php b/tests/Unit/Services/Reports/ReportProcessedQueryServiceTest.php index 31a15cf..532b6af 100644 --- a/tests/Unit/Services/Reports/ReportProcessedQueryServiceTest.php +++ b/tests/Unit/Services/Reports/ReportProcessedQueryServiceTest.php @@ -1590,6 +1590,41 @@ public function shouldMapToStrictSegmentGuidance( } } + public function testMapsMessageBasedAccessLikeCoreFailureToReportNotFound(): void + { + $service = $this->makeService( + metadataWrapper: $this->makeMetadataWrapper(), + processedReportCaller: static function (): array { + throw new CoreApiRequestException( + 'core failed', + 0, + new \RuntimeException('CheckUserHasViewAccess failed'), + ); + }, + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Report not found.'); + + $service->getProcessedReport( + idSite: 1, + period: 'day', + date: 'today', + reportUniqueId: 'Actions_getPageUrls', + apiModule: null, + apiAction: null, + apiParameters: [], + goalMetricsMode: null, + goalMetricsProcessGoals: null, + segment: null, + idGoal: null, + idDimension: null, + idSubtable: null, + filterLimit: 10, + filterOffset: 0, + ); + } + public function testKeepsGenericFailureWhenSegmentIsPreprocessedInStrictMode(): void { $service = $this->makeService( diff --git a/tests/Unit/Services/Segments/CoreSegmentEditorGatewayTest.php b/tests/Unit/Services/Segments/CoreSegmentEditorGatewayTest.php index b43018c..cc09d35 100644 --- a/tests/Unit/Services/Segments/CoreSegmentEditorGatewayTest.php +++ b/tests/Unit/Services/Segments/CoreSegmentEditorGatewayTest.php @@ -14,6 +14,7 @@ use Matomo\Dependencies\McpServer\Mcp\Exception\ToolCallException; use PHPUnit\Framework\TestCase; use Piwik\Plugins\McpServer\Services\Segments\CoreSegmentEditorGateway; +use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; /** * @group McpServer @@ -70,4 +71,17 @@ static function (string $method, array $paramOverride, array $defaultRequest): a $this->expectExceptionMessage('Segment data is invalid.'); $gateway->getAll(9); } + + public function testGetAllMapsMessageBasedAccessFailure(): void + { + $gateway = new CoreSegmentEditorGateway( + static function (string $method, array $paramOverride, array $defaultRequest): mixed { + throw new \RuntimeException('No access to this resource'); + }, + ); + + $this->expectException(AccessDeniedLikeException::class); + $this->expectExceptionMessage('No access to this resource.'); + $gateway->getAll(9); + } } diff --git a/tests/Unit/Services/Segments/SegmentDetailQueryServiceTest.php b/tests/Unit/Services/Segments/SegmentDetailQueryServiceTest.php index 921dba3..13b9b0b 100644 --- a/tests/Unit/Services/Segments/SegmentDetailQueryServiceTest.php +++ b/tests/Unit/Services/Segments/SegmentDetailQueryServiceTest.php @@ -111,6 +111,27 @@ public function testNormalizeSegmentDetailRowsThrowsWhenRowIsNotArray(): void ); } + public function testGetSegmentDetailsForSiteMapsMessageBasedAccessFailureToNotFound(): void + { + $gateway = $this->createMock(CoreSegmentEditorGatewayInterface::class); + $gateway->expects(self::once()) + ->method('getAll') + ->with(9) + ->willThrowException(new \RuntimeException('CheckUserHasViewAccess failed')); + + $capabilityGateway = $this->createMock(PluginCapabilityGatewayInterface::class); + $capabilityGateway->expects(self::once()) + ->method('isPluginActivated') + ->with('SegmentEditor') + ->willReturn(true); + + $service = new SegmentDetailQueryService($gateway, $capabilityGateway); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Segment not found.'); + $service->getSegmentDetailsForSite(9); + } + /** * @return array */ diff --git a/tests/Unit/Services/Sites/CoreSitesManagerGatewayTest.php b/tests/Unit/Services/Sites/CoreSitesManagerGatewayTest.php index e66b8c0..d14f095 100644 --- a/tests/Unit/Services/Sites/CoreSitesManagerGatewayTest.php +++ b/tests/Unit/Services/Sites/CoreSitesManagerGatewayTest.php @@ -14,6 +14,7 @@ use Matomo\Dependencies\McpServer\Mcp\Exception\ToolCallException; use PHPUnit\Framework\TestCase; use Piwik\Plugins\McpServer\Services\Sites\CoreSitesManagerGateway; +use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; /** * @group McpServer @@ -99,4 +100,17 @@ static function (string $method, array $paramOverride, array $defaultRequest): a $this->expectExceptionMessage('Site data is invalid.'); $gateway->getSiteFromId(4); } + + public function testGetSiteFromIdMapsMessageBasedAccessFailure(): void + { + $gateway = new CoreSitesManagerGateway( + static function (string $method, array $paramOverride, array $defaultRequest): mixed { + throw new \RuntimeException('CheckUserHasViewAccess failed'); + }, + ); + + $this->expectException(AccessDeniedLikeException::class); + $this->expectExceptionMessage('No access to this resource.'); + $gateway->getSiteFromId(4); + } } diff --git a/tests/Unit/Services/Sites/SiteDetailQueryServiceTest.php b/tests/Unit/Services/Sites/SiteDetailQueryServiceTest.php index ac2a28c..81369ff 100644 --- a/tests/Unit/Services/Sites/SiteDetailQueryServiceTest.php +++ b/tests/Unit/Services/Sites/SiteDetailQueryServiceTest.php @@ -13,8 +13,10 @@ use Matomo\Dependencies\McpServer\Mcp\Exception\ToolCallException; use PHPUnit\Framework\TestCase; +use Piwik\NoAccessException; use Piwik\Plugins\McpServer\Contracts\Ports\Sites\CoreSitesManagerGatewayInterface; use Piwik\Plugins\McpServer\Services\Sites\SiteDetailQueryService; +use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; /** * @group McpServer @@ -67,6 +69,38 @@ public function testNormalizeSiteDetailDataReturnsExpectedTypedOutput(): void ], $site->toArray()); } + public function testGetSiteDetailFromIdMapsMessageBasedAccessFailureToNotFoundOrAccessDenied(): void + { + $gateway = $this->createMock(CoreSitesManagerGatewayInterface::class); + $gateway->expects(self::once()) + ->method('getSiteFromId') + ->with(3) + ->willThrowException(new \RuntimeException('CheckUserHasViewAccess failed')); + + $service = new SiteDetailQueryService($gateway); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Site not found or access denied.'); + $service->getSiteDetailFromId(3); + } + + public function testGetSiteDetailFromIdMapsTypeBasedAccessFailureWithEmptyMessageToNotFoundOrAccessDenied(): void + { + foreach ([new NoAccessException(''), new AccessDeniedLikeException('')] as $exception) { + $gateway = $this->createMock(CoreSitesManagerGatewayInterface::class); + $gateway->method('getSiteFromId')->willThrowException($exception); + + $service = new SiteDetailQueryService($gateway); + + try { + $service->getSiteDetailFromId(3); + self::fail('Expected ToolCallException was not thrown for ' . get_class($exception)); + } catch (ToolCallException $e) { + self::assertSame('Site not found or access denied.', $e->getMessage()); + } + } + } + /** * @return array */ diff --git a/tests/Unit/Support/Errors/NoAccessLikeErrorDetectorTest.php b/tests/Unit/Support/Errors/NoAccessLikeErrorDetectorTest.php new file mode 100644 index 0000000..274c426 --- /dev/null +++ b/tests/Unit/Support/Errors/NoAccessLikeErrorDetectorTest.php @@ -0,0 +1,53 @@ + Date: Thu, 12 Mar 2026 12:48:27 +0100 Subject: [PATCH 05/15] Migrate raw API access mode configuration to system settings --- McpServerFactory.php | 9 +-- McpTools/ApiCall.php | 11 +-- McpTools/ApiGet.php | 11 +-- McpTools/ApiList.php | 6 +- SystemSettings.php | 31 ++++++++ docs/faq.md | 17 ++--- lang/en.json | 10 ++- tests/Framework/McpTestHelper.php | 19 +++++ tests/Integration/McpServerTest.php | 8 +++ tests/Integration/McpTools/ApiCallTest.php | 30 +++++--- tests/Integration/McpTools/ApiGetTest.php | 33 ++++++--- tests/Integration/McpTools/ApiListTest.php | 35 +++++++--- .../McpToolsContractBaselineTest.php | 17 +++-- tests/Integration/McpToolsContractTest.php | 25 +++++-- tests/Integration/SystemSettingsTest.php | 59 +++++++++++++++- tests/UI/McpServer_spec.js | 55 ++++++++++++--- tests/Unit/APITest.php | 1 + tests/Unit/McpServerFactoryTest.php | 54 +++++++++----- tests/Unit/McpTools/ApiCallTest.php | 33 ++++----- tests/Unit/McpTools/ApiGetTest.php | 33 ++++----- tests/Unit/McpTools/ApiListTest.php | 70 +++++++++---------- 21 files changed, 388 insertions(+), 179 deletions(-) diff --git a/McpServerFactory.php b/McpServerFactory.php index 712173f..ed82369 100644 --- a/McpServerFactory.php +++ b/McpServerFactory.php @@ -46,6 +46,7 @@ public function __construct( private SessionStoreInterface $sessionStore, private ContainerInterface $container, private ToolCallParameterFormatter $toolCallParameterFormatter, + private SystemSettings $systemSettings, ) { } @@ -76,7 +77,7 @@ public function createServer(): Server completions: false, )); - $rawApiAccessMode = $this->resolveRawApiAccessMode(); + $rawApiAccessMode = $this->systemSettings->getRawApiAccessMode(); if (RawApiAccessMode::allowsToolRegistration($rawApiAccessMode)) { $rawApiCallDestructiveHint = $rawApiAccessMode === RawApiAccessMode::FULL; $builder->addTool( @@ -205,12 +206,6 @@ private function resolveToolCallLogLevel(array $config): string return $normalizedLevel; } - private function resolveRawApiAccessMode(): string - { - $config = $this->getMcpServerConfig(); - return RawApiAccessMode::normalize($config['raw_api_access_mode'] ?? null); - } - /** * @return array */ diff --git a/McpTools/ApiCall.php b/McpTools/ApiCall.php index b17ca75..3a7f38c 100644 --- a/McpTools/ApiCall.php +++ b/McpTools/ApiCall.php @@ -11,10 +11,9 @@ namespace Piwik\Plugins\McpServer\McpTools; -use Piwik\Config; use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiCallQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiCallRecord; -use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; +use Piwik\Plugins\McpServer\SystemSettings; /** * @phpstan-import-type ApiCallArray from ApiCallRecord @@ -23,8 +22,10 @@ class ApiCall { public const TOOL_NAME = 'matomo_api_call'; - public function __construct(private ApiCallQueryServiceInterface $queryService) - { + public function __construct( + private ApiCallQueryServiceInterface $queryService, + private SystemSettings $systemSettings, + ) { } /** @@ -38,7 +39,7 @@ public function call( ?array $parameters = null, ): array { return $this->queryService->callApi( - RawApiAccessMode::normalize(Config::getInstance()->McpServer['raw_api_access_mode'] ?? null), + $this->systemSettings->getRawApiAccessMode(), $method, $module, $action, diff --git a/McpTools/ApiGet.php b/McpTools/ApiGet.php index 9d9e5e9..f8c6fc0 100644 --- a/McpTools/ApiGet.php +++ b/McpTools/ApiGet.php @@ -11,10 +11,9 @@ namespace Piwik\Plugins\McpServer\McpTools; -use Piwik\Config; use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiMethodSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; -use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; +use Piwik\Plugins\McpServer\SystemSettings; /** * @phpstan-import-type ApiMethodSummaryArray from ApiMethodSummaryRecord @@ -23,8 +22,10 @@ class ApiGet { public const TOOL_NAME = 'matomo_api_get'; - public function __construct(private ApiMethodSummaryQueryServiceInterface $queryService) - { + public function __construct( + private ApiMethodSummaryQueryServiceInterface $queryService, + private SystemSettings $systemSettings, + ) { } /** @@ -33,7 +34,7 @@ public function __construct(private ApiMethodSummaryQueryServiceInterface $query public function get(?string $method = null, ?string $module = null, ?string $action = null): array { return $this->queryService->getApiMethodSummaryBySelector( - RawApiAccessMode::normalize(Config::getInstance()->McpServer['raw_api_access_mode'] ?? null), + $this->systemSettings->getRawApiAccessMode(), $method, $module, $action, diff --git a/McpTools/ApiList.php b/McpTools/ApiList.php index 4b48908..1aa8942 100644 --- a/McpTools/ApiList.php +++ b/McpTools/ApiList.php @@ -11,14 +11,13 @@ namespace Piwik\Plugins\McpServer\McpTools; -use Piwik\Config; use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiMethodSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryQueryRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; -use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\Support\Pagination\ApiMethodsPagination; use Piwik\Plugins\McpServer\Support\Tooling\CursorContextBuilder; use Piwik\Plugins\McpServer\Support\Tooling\PaginatedCollectionResponder; +use Piwik\Plugins\McpServer\SystemSettings; /** * @phpstan-import-type ApiMethodSummaryArray from ApiMethodSummaryRecord @@ -30,6 +29,7 @@ class ApiList public function __construct( private ApiMethodSummaryQueryServiceInterface $queryService, private PaginatedCollectionResponder $paginationResponder, + private SystemSettings $systemSettings, ) { } @@ -49,7 +49,7 @@ public function list( ?string $search = null, ): array { $query = ApiMethodSummaryQueryRecord::fromInputs( - RawApiAccessMode::normalize(Config::getInstance()->McpServer['raw_api_access_mode'] ?? null), + $this->systemSettings->getRawApiAccessMode(), $module, $search, ); diff --git a/SystemSettings.php b/SystemSettings.php index 305fda3..776e181 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -12,6 +12,7 @@ namespace Piwik\Plugins\McpServer; use Piwik\Piwik; +use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Settings\FieldConfig; use Piwik\Settings\Setting; use Piwik\SettingsPiwik; @@ -21,6 +22,9 @@ class SystemSettings extends \Piwik\Settings\Plugin\SystemSettings /** @var Setting */ public $enableMcp; + /** @var Setting */ + public $rawApiAccessMode; + protected function init(): void { $this->enableMcp = $this->makeSetting( @@ -42,6 +46,28 @@ function (FieldConfig $field) { $field->uiControl = FieldConfig::UI_CONTROL_CHECKBOX; }, ); + + $this->rawApiAccessMode = $this->makeSetting( + 'raw_api_access_mode', + RawApiAccessMode::NONE, + FieldConfig::TYPE_STRING, + function (FieldConfig $field) { + $field->title = Piwik::translate('McpServer_RawApiAccessModeTitle'); + $field->inlineHelp = implode('

', [ + Piwik::translate('McpServer_RawApiAccessModeHelpPurpose'), + Piwik::translate('McpServer_RawApiAccessModeHelpDataScope'), + Piwik::translate('McpServer_RawApiAccessModeHelpDestructive'), + Piwik::translate('McpServer_RawApiAccessModeHelpPolicy'), + ]); + $field->uiControl = FieldConfig::UI_CONTROL_SINGLE_SELECT; + $field->condition = 'enable_mcp==1'; + $field->availableValues = [ + RawApiAccessMode::NONE => Piwik::translate('McpServer_RawApiAccessModeOptionNone'), + RawApiAccessMode::READ => Piwik::translate('McpServer_RawApiAccessModeOptionRead'), + RawApiAccessMode::FULL => Piwik::translate('McpServer_RawApiAccessModeOptionFull'), + ]; + }, + ); } public function isMcpEnabled(): bool @@ -49,6 +75,11 @@ public function isMcpEnabled(): bool return (bool) $this->enableMcp->getValue(); } + public function getRawApiAccessMode(): string + { + return RawApiAccessMode::normalize($this->rawApiAccessMode->getValue()); + } + private function getMcpEndpointUrl(): string { return $this->getNormalizedBaseUrl() . '/index.php?module=API&method=McpServer.mcp&format=mcp'; diff --git a/docs/faq.md b/docs/faq.md index 255b700..6a9445b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -27,17 +27,13 @@ log_tool_call_parameters_full = 0 - `log_tool_call_level`: Tool-call logging level when `log_tool_calls = 1`. Accepted values: `ERROR`, `WARN`/`WARNING`, `INFO`, `DEBUG`, `VERBOSE` (case-insensitive). Missing or invalid values default to `DEBUG`. `VERBOSE` is logged via debug-level logger calls. - `log_tool_call_parameters_full`: Logs full tool-call parameter values when set to `1`. Default is redacted parameter logging when set to `0` (may expose sensitive input data when enabled). -Configure raw Matomo API tool access in `config/config.ini.php`: +Configure raw Matomo API tool access in **Administration -> System -> Plugin Settings -> McpServer**: -```ini -[McpServer] -raw_api_access_mode = none -``` - -- `raw_api_access_mode`: Controls raw API tool visibility for `matomo_api_list`, `matomo_api_get`, and `matomo_api_call`. -- `none`: hides `matomo_api_list`, `matomo_api_get`, and `matomo_api_call` (default). -- `read`: shows the raw API tools and currently allows only API actions with `get`/`is` prefix. This prefix-based filter is a temporary heuristic and may be replaced by a more accurate read/write classification in the future. -- `full`: shows the raw API tools and allows all discoverable API actions. +- Use the **Raw Matomo API tool access** setting to control visibility for `matomo_api_list`, `matomo_api_get`, and `matomo_api_call`. +- `Disabled`: hides `matomo_api_list`, `matomo_api_get`, and `matomo_api_call` (default). +- `Read only`: shows the raw API tools and allows only the current read-only heuristic (`get`/`is` methods). +- `Full API access`: shows the raw API tools and allows direct API calls, including state-changing or destructive methods. +- Direct API access can expose raw or personal data depending on enabled Matomo features. Review privacy and security requirements before enabling it, and consult your DPO or compliance owner when needed. ## Enabling MCP @@ -59,6 +55,7 @@ The plugin is focused on read-oriented analytics workflows. The exact tool surfa - goals - segments - dimensions +- raw Matomo API discovery and execution, when enabled by an administrator ## Troubleshooting diff --git a/lang/en.json b/lang/en.json index 8a5e9a0..8d45db8 100644 --- a/lang/en.json +++ b/lang/en.json @@ -31,6 +31,14 @@ "EnableMcpHelpPolicy": "Before enabling, ensure this complies with your organization's privacy policies and applicable regulations. You may need approval from your data protection officer (DPO) or to update your privacy policy.", "EnableMcpHelpPurpose": "Enable the Matomo MCP Server (Model Context Protocol) to allow AI tools and assistants to access analytics context from your Matomo instance.", "EnableMcpHelpUrl": "Your MCP URL: %1$s%2$s%3$s", - "EnableMcpTitle": "Enable MCP Server (Model Context Protocol)" + "EnableMcpTitle": "Enable MCP Server (Model Context Protocol)", + "RawApiAccessModeHelpDataScope": "Direct Matomo API access can expose the same data available through the Matomo user interface or Reporting API, including raw or personal data when features such as the Visitor Log are enabled.", + "RawApiAccessModeHelpDestructive": "Full API access can execute state-changing or destructive API methods, including actions that modify configuration or delete data.", + "RawApiAccessModeHelpPolicy": "Before enabling direct API access, ensure this complies with your organization's privacy and security policies and applicable regulations. You may need approval from your data protection officer (DPO) or another compliance owner.", + "RawApiAccessModeHelpPurpose": "Control whether the raw Matomo API tools are hidden, limited to read-only methods, or allowed to execute direct API calls.", + "RawApiAccessModeOptionFull": "Full API access", + "RawApiAccessModeOptionNone": "Disabled", + "RawApiAccessModeOptionRead": "Read only", + "RawApiAccessModeTitle": "Raw Matomo API tool access" } } diff --git a/tests/Framework/McpTestHelper.php b/tests/Framework/McpTestHelper.php index ad3fe0e..3b54a0f 100644 --- a/tests/Framework/McpTestHelper.php +++ b/tests/Framework/McpTestHelper.php @@ -35,6 +35,7 @@ use Piwik\Access; use Piwik\Container\StaticContainer; use Piwik\Plugins\McpServer\McpServerFactory; +use Piwik\Plugins\McpServer\SystemSettings; /** * @phpstan-import-type ToolData from Tool @@ -69,6 +70,24 @@ public static function initializeSession(Server $server): string return $sessionId; } + public static function getRawApiAccessMode(): string + { + return StaticContainer::get(SystemSettings::class)->getRawApiAccessMode(); + } + + public static function setRawApiAccessMode(string $rawApiAccessMode): void + { + $access = Access::getInstance(); + $hadSuperUserAccess = $access->hasSuperUserAccess(); + $access->setSuperUserAccess(true); + + try { + StaticContainer::get(SystemSettings::class)->rawApiAccessMode->setValue($rawApiAccessMode); + } finally { + $access->setSuperUserAccess($hadSuperUserAccess); + } + } + /** * @param array|array|string $payload * @param array $headers diff --git a/tests/Integration/McpServerTest.php b/tests/Integration/McpServerTest.php index 623511a..c59ef8e 100644 --- a/tests/Integration/McpServerTest.php +++ b/tests/Integration/McpServerTest.php @@ -130,6 +130,7 @@ public function testContainerSystemSettingCanBeToggled(): void $systemSettings = StaticContainer::get(SystemSettings::class); self::assertInstanceOf(SystemSettings::class, $systemSettings); $originalEnableMcpValue = (bool) $systemSettings->enableMcp->getValue(); + $originalRawApiAccessMode = $systemSettings->getRawApiAccessMode(); Access::getInstance()->setSuperUserAccess(true); @@ -139,8 +140,15 @@ public function testContainerSystemSettingCanBeToggled(): void $systemSettings->enableMcp->setValue(true); self::assertTrue($systemSettings->isMcpEnabled()); + + $systemSettings->rawApiAccessMode->setValue('read'); + self::assertSame('read', $systemSettings->getRawApiAccessMode()); + + $systemSettings->rawApiAccessMode->setValue('full'); + self::assertSame('full', $systemSettings->getRawApiAccessMode()); } finally { $systemSettings->enableMcp->setValue($originalEnableMcpValue); + $systemSettings->rawApiAccessMode->setValue($originalRawApiAccessMode); Access::getInstance()->setSuperUserAccess(false); } } diff --git a/tests/Integration/McpTools/ApiCallTest.php b/tests/Integration/McpTools/ApiCallTest.php index 819652d..e402d3f 100644 --- a/tests/Integration/McpTools/ApiCallTest.php +++ b/tests/Integration/McpTools/ApiCallTest.php @@ -13,7 +13,6 @@ use Matomo\Dependencies\McpServer\Mcp\Schema\JsonRpc\Error as JsonRpcError; use Piwik\API\Request; -use Piwik\Config; use Piwik\DataTable\DataTableInterface; use Piwik\DataTable\Renderer\Json; use Piwik\Plugins\McpServer\McpTools\ApiCall; @@ -27,6 +26,7 @@ */ class ApiCallTest extends IntegrationTestCase { + private string $originalRawApiAccessMode = 'none'; private int $idSite = 0; protected static function configureFixture($fixture): void @@ -40,6 +40,7 @@ public function setUp(): void { parent::setUp(); + $this->originalRawApiAccessMode = McpTestHelper::getRawApiAccessMode(); $this->idSite = Fixture::createWebsite( '2015-01-01 00:00:00', 0, @@ -59,9 +60,16 @@ public function setUp(): void Fixture::checkResponse($tracker->doTrackPageView('page-b')); } + public function tearDown(): void + { + McpTestHelper::setRawApiAccessMode($this->originalRawApiAccessMode); + + parent::tearDown(); + } + public function testReadModeCallsKnownReadMethodByMethodSelector(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -85,7 +93,7 @@ public function testReadModeCallsKnownReadMethodByMethodSelector(): void public function testReadModeCallsKnownReadMethodByModuleAndActionSelector(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -106,7 +114,7 @@ public function testReadModeCallsKnownReadMethodByModuleAndActionSelector(): voi public function testReadModeNormalizesDataTableResponse(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $baseline = Request::processRequest('Actions.getPageUrls', [ 'idSite' => $this->idSite, @@ -141,7 +149,7 @@ public function testReadModeNormalizesDataTableResponse(): void public function testReadModeRejectsWriteOnlyMethod(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -157,7 +165,7 @@ public function testReadModeRejectsWriteOnlyMethod(): void public function testFullModeAttemptsMutatingMethodCall(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -182,7 +190,7 @@ public function testFullModeAttemptsMutatingMethodCall(): void public function testRejectsReservedParameterKeys(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -201,7 +209,7 @@ public function testRejectsReservedParameterKeys(): void public function testRejectsMissingSelectorAtSchemaLevel(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -221,7 +229,7 @@ public function testRejectsMissingSelectorAtSchemaLevel(): void public function testRejectsMixedSelectorStyleAtSchemaLevel(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -243,7 +251,7 @@ public function testRejectsMixedSelectorStyleAtSchemaLevel(): void public function testSchemaDeclaresFlatSelectorsWithoutTopLevelCombinators(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -273,7 +281,7 @@ public function testSchemaDeclaresFlatSelectorsWithoutTopLevelCombinators(): voi public function testNoneModeHidesAndRejectsToolCall(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; + McpTestHelper::setRawApiAccessMode('none'); self::assertNotContains(ApiCall::TOOL_NAME, $this->listToolNamesForCurrentConfig()); $server = McpTestHelper::buildServer(); diff --git a/tests/Integration/McpTools/ApiGetTest.php b/tests/Integration/McpTools/ApiGetTest.php index a2dc7aa..5a97d84 100644 --- a/tests/Integration/McpTools/ApiGetTest.php +++ b/tests/Integration/McpTools/ApiGetTest.php @@ -12,7 +12,6 @@ namespace Piwik\Plugins\McpServer\tests\Integration\McpTools; use Matomo\Dependencies\McpServer\Mcp\Schema\JsonRpc\Error as JsonRpcError; -use Piwik\Config; use Piwik\Plugins\McpServer\McpTools\ApiGet; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -23,9 +22,25 @@ */ class ApiGetTest extends IntegrationTestCase { + private string $originalRawApiAccessMode = 'none'; + + public function setUp(): void + { + parent::setUp(); + + $this->originalRawApiAccessMode = McpTestHelper::getRawApiAccessMode(); + } + + public function tearDown(): void + { + McpTestHelper::setRawApiAccessMode($this->originalRawApiAccessMode); + + parent::tearDown(); + } + public function testReadModeReturnsKnownReadMethodByMethodSelector(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -45,7 +60,7 @@ public function testReadModeReturnsKnownReadMethodByMethodSelector(): void public function testFullModeReturnsKnownMutatingMethodByModuleAndActionSelectors(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -65,7 +80,7 @@ public function testFullModeReturnsKnownMutatingMethodByModuleAndActionSelectors public function testReadModeRejectsWriteOnlyMethod(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -81,7 +96,7 @@ public function testReadModeRejectsWriteOnlyMethod(): void public function testRejectsIncompleteSplitSelectorAtSchemaLevel(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -98,7 +113,7 @@ public function testRejectsIncompleteSplitSelectorAtSchemaLevel(): void public function testRejectsMissingSelectorAtSchemaLevel(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -115,7 +130,7 @@ public function testRejectsMissingSelectorAtSchemaLevel(): void public function testRejectsMixedSelectorStyleAtSchemaLevel(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -137,7 +152,7 @@ public function testRejectsMixedSelectorStyleAtSchemaLevel(): void public function testSchemaDeclaresFlatSelectorsWithoutTopLevelCombinators(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -177,7 +192,7 @@ public function testSchemaDeclaresFlatSelectorsWithoutTopLevelCombinators(): voi public function testNoneModeHidesAndRejectsToolCall(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; + McpTestHelper::setRawApiAccessMode('none'); self::assertNotContains(ApiGet::TOOL_NAME, $this->listToolNamesForCurrentConfig()); $server = McpTestHelper::buildServer(); diff --git a/tests/Integration/McpTools/ApiListTest.php b/tests/Integration/McpTools/ApiListTest.php index 04aea76..bf1ddec 100644 --- a/tests/Integration/McpTools/ApiListTest.php +++ b/tests/Integration/McpTools/ApiListTest.php @@ -12,7 +12,6 @@ namespace Piwik\Plugins\McpServer\tests\Integration\McpTools; use Matomo\Dependencies\McpServer\Mcp\Schema\JsonRpc\Error as JsonRpcError; -use Piwik\Config; use Piwik\Plugins\McpServer\Support\Pagination\ApiMethodsPagination; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -23,9 +22,25 @@ */ class ApiListTest extends IntegrationTestCase { + private string $originalRawApiAccessMode = 'none'; + + public function setUp(): void + { + parent::setUp(); + + $this->originalRawApiAccessMode = McpTestHelper::getRawApiAccessMode(); + } + + public function tearDown(): void + { + McpTestHelper::setRawApiAccessMode($this->originalRawApiAccessMode); + + parent::tearDown(); + } + public function testReadModeExposesReadOnlyActionsOnly(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -55,7 +70,7 @@ public function testReadModeExposesReadOnlyActionsOnly(): void public function testFullModeCanReturnMutatingActions(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -89,7 +104,7 @@ public function testFullModeCanReturnMutatingActions(): void public function testReturnsPagedResultsWithCursor(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -137,7 +152,7 @@ public function testReturnsPagedResultsWithCursor(): void public function testRejectsInvalidLimit(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -155,7 +170,7 @@ public function testRejectsInvalidLimit(): void public function testRejectsInvalidSort(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -173,7 +188,7 @@ public function testRejectsInvalidSort(): void public function testRejectsInvalidCursor(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -189,7 +204,7 @@ public function testRejectsInvalidCursor(): void public function testRejectsCursorSortMismatch(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -216,7 +231,7 @@ public function testRejectsCursorSortMismatch(): void public function testRejectsCursorFromDifferentFilterContext(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -243,7 +258,7 @@ public function testRejectsCursorFromDifferentFilterContext(): void public function testNoneModeHidesAndRejectsToolCall(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; + McpTestHelper::setRawApiAccessMode('none'); self::assertNotContains('matomo_api_list', $this->listToolNamesForCurrentConfig()); $server = McpTestHelper::buildServer(); diff --git a/tests/Integration/McpToolsContractBaselineTest.php b/tests/Integration/McpToolsContractBaselineTest.php index 7982008..4f698be 100644 --- a/tests/Integration/McpToolsContractBaselineTest.php +++ b/tests/Integration/McpToolsContractBaselineTest.php @@ -12,7 +12,6 @@ namespace Piwik\Plugins\McpServer\tests\Integration; use Matomo\Dependencies\McpServer\Mcp\Server; -use Piwik\Config; use Piwik\Plugins\API\API as ApiModuleApi; use Piwik\Plugins\CustomDimensions\API as CustomDimensionsApi; use Piwik\Plugins\Goals\API as GoalsApi; @@ -60,6 +59,7 @@ class McpToolsContractBaselineTest extends IntegrationTestCase private int $idDimension = 0; private int $idGoal = 0; private string $reportUniqueId = ''; + private string $originalRawApiAccessMode = 'none'; protected static function configureFixture($fixture): void { @@ -72,6 +72,8 @@ public function setUp(): void { parent::setUp(); + $this->originalRawApiAccessMode = McpTestHelper::getRawApiAccessMode(); + $suffix = substr(hash('sha256', __METHOD__ . microtime(true)), 0, 8); $this->idSite = Fixture::createWebsite( '2015-01-01 00:00:00', @@ -128,6 +130,13 @@ public function setUp(): void $this->reportUniqueId = $reportUniqueId; } + public function tearDown(): void + { + McpTestHelper::setRawApiAccessMode($this->originalRawApiAccessMode); + + parent::tearDown(); + } + /** * @dataProvider provideSuccessCases * @@ -242,7 +251,7 @@ public function testReportListSerializesEmptyParametersAsObjectInBaselineRespons public function testApiListSuccessShapeInReadMode(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -260,7 +269,7 @@ public function testApiListSuccessShapeInReadMode(): void public function testApiGetSuccessShapeInReadMode(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -277,7 +286,7 @@ public function testApiGetSuccessShapeInReadMode(): void public function testApiCallSuccessShapeInReadMode(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); diff --git a/tests/Integration/McpToolsContractTest.php b/tests/Integration/McpToolsContractTest.php index aecc178..0715741 100644 --- a/tests/Integration/McpToolsContractTest.php +++ b/tests/Integration/McpToolsContractTest.php @@ -12,7 +12,6 @@ namespace Piwik\Plugins\McpServer\tests\Integration; use Matomo\Dependencies\McpServer\Mcp\Schema\Tool; -use Piwik\Config; use Piwik\Plugins\McpServer\McpTools\ApiCall; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -23,9 +22,25 @@ */ class McpToolsContractTest extends IntegrationTestCase { + private string $originalRawApiAccessMode = 'none'; + + public function setUp(): void + { + parent::setUp(); + + $this->originalRawApiAccessMode = McpTestHelper::getRawApiAccessMode(); + } + + public function tearDown(): void + { + McpTestHelper::setRawApiAccessMode($this->originalRawApiAccessMode); + + parent::tearDown(); + } + public function testToolsListContainsAllPluginTools(): void { - Config::getInstance()->McpServer = []; + McpTestHelper::setRawApiAccessMode('none'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -146,7 +161,7 @@ public function testToolsListContainsAllPluginTools(): void public function testRawApiListToolIsHiddenWhenRawAccessModeIsNone(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; + McpTestHelper::setRawApiAccessMode('none'); $toolsByName = $this->listToolsByNameForCurrentConfig(); self::assertArrayNotHasKey(ApiCall::TOOL_NAME, $toolsByName); @@ -156,7 +171,7 @@ public function testRawApiListToolIsHiddenWhenRawAccessModeIsNone(): void public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessModeIsRead(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; + McpTestHelper::setRawApiAccessMode('read'); $toolsByName = $this->listToolsByNameForCurrentConfig(); self::assertArrayHasKey('matomo_api_get', $toolsByName); @@ -186,7 +201,7 @@ public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessM public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessModeIsFull(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + McpTestHelper::setRawApiAccessMode('full'); $toolsByName = $this->listToolsByNameForCurrentConfig(); self::assertArrayHasKey('matomo_api_get', $toolsByName); diff --git a/tests/Integration/SystemSettingsTest.php b/tests/Integration/SystemSettingsTest.php index d7a190b..bd938a8 100644 --- a/tests/Integration/SystemSettingsTest.php +++ b/tests/Integration/SystemSettingsTest.php @@ -11,6 +11,8 @@ namespace Piwik\Plugins\McpServer\tests\Integration; +use Piwik\Access; +use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\SystemSettings; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -21,12 +23,33 @@ class SystemSettingsTest extends IntegrationTestCase { private ?SystemSettings $settings = null; + private bool $originalEnableMcp = false; + private string $originalRawApiAccessMode = RawApiAccessMode::NONE; public function setUp(): void { parent::setUp(); $this->settings = new SystemSettings(); + self::assertInstanceOf(SystemSettings::class, $this->settings); + $this->originalEnableMcp = $this->settings->isMcpEnabled(); + $this->originalRawApiAccessMode = $this->settings->getRawApiAccessMode(); + } + + public function tearDown(): void + { + self::assertInstanceOf(SystemSettings::class, $this->settings); + $hadSuperUserAccess = Access::getInstance()->hasSuperUserAccess(); + Access::getInstance()->setSuperUserAccess(true); + + try { + $this->settings->enableMcp->setValue($this->originalEnableMcp); + $this->settings->rawApiAccessMode->setValue($this->originalRawApiAccessMode); + } finally { + Access::getInstance()->setSuperUserAccess($hadSuperUserAccess); + } + + parent::tearDown(); } public function testMcpIsDisabledByDefault(): void @@ -38,7 +61,39 @@ public function testMcpIsDisabledByDefault(): void public function testCanEnableMcp(): void { self::assertInstanceOf(SystemSettings::class, $this->settings); - $this->settings->enableMcp->setValue(true); - self::assertTrue($this->settings->isMcpEnabled()); + $access = Access::getInstance(); + $hadSuperUserAccess = $access->hasSuperUserAccess(); + $access->setSuperUserAccess(true); + + try { + $this->settings->enableMcp->setValue(true); + self::assertTrue($this->settings->isMcpEnabled()); + } finally { + $access->setSuperUserAccess($hadSuperUserAccess); + } + } + + public function testRawApiAccessModeDefaultsToNone(): void + { + self::assertInstanceOf(SystemSettings::class, $this->settings); + self::assertSame(RawApiAccessMode::NONE, $this->settings->getRawApiAccessMode()); + } + + public function testCanChangeRawApiAccessMode(): void + { + self::assertInstanceOf(SystemSettings::class, $this->settings); + $access = Access::getInstance(); + $hadSuperUserAccess = $access->hasSuperUserAccess(); + $access->setSuperUserAccess(true); + + try { + $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::READ); + self::assertSame(RawApiAccessMode::READ, $this->settings->getRawApiAccessMode()); + + $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::FULL); + self::assertSame(RawApiAccessMode::FULL, $this->settings->getRawApiAccessMode()); + } finally { + $access->setSuperUserAccess($hadSuperUserAccess); + } } } diff --git a/tests/UI/McpServer_spec.js b/tests/UI/McpServer_spec.js index d41ceff..054dc93 100644 --- a/tests/UI/McpServer_spec.js +++ b/tests/UI/McpServer_spec.js @@ -14,6 +14,7 @@ describe('McpServer', function () { const connectUrl = '?module=McpServer&action=connect&idSite=1&period=day&date=yesterday'; const settingsSelector = '#McpServerPluginSettings'; const enabledCheckboxSelector = 'input[name="enable_mcp"]'; + const rawApiAccessModeSelector = 'select[name="raw_api_access_mode"]'; const settingsSaveButtonSelector = `${settingsSelector} .pluginsSettingsSubmit`; const connectSelector = '.mcpServerConnect'; @@ -54,18 +55,45 @@ describe('McpServer', function () { await page.waitForNetworkIdle(); } - async function setMcpEnabled(enabled) + async function isRawApiAccessModeVisible() + { + return page.evaluate((selector) => { + const element = document.querySelector(selector); + + if (!element) { + return false; + } + + return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); + }, rawApiAccessModeSelector); + } + + async function configureMcp(enabled, rawApiAccessMode = 'string:read') { resetUserToSuperUser(); await page.goto(settingsUrl); await waitForSettingsSection(); - const isChecked = await page.$eval(enabledCheckboxSelector, (el) => !!el.checked); + const isEnabled = await page.$eval(enabledCheckboxSelector, (el) => !!el.checked); - if (isChecked !== enabled) { + if (isEnabled !== enabled) { await page.click(`${enabledCheckboxSelector} + span`); await page.waitForTimeout(250); await saveSettings(); + + await page.goto(settingsUrl); + await waitForSettingsSection(); + } + + if (enabled) { + await page.waitForSelector(rawApiAccessModeSelector, { visible: true }); + const currentRawApiAccessMode = await page.$eval(rawApiAccessModeSelector, (el) => el.value); + + if (currentRawApiAccessMode !== rawApiAccessMode) { + await page.select(rawApiAccessModeSelector, rawApiAccessMode); + await page.waitForTimeout(250); + await saveSettings(); + } } await page.goto(settingsUrl); @@ -92,14 +120,23 @@ describe('McpServer', function () { resetUserToSuperUser(); }); - it('should display the plugin settings', async function () { - await setMcpEnabled(false); + it('should only show the enable checkbox when MCP is disabled', async function () { + await configureMcp(false); + + expect(await page.$eval(enabledCheckboxSelector, (el) => !!el.checked)).to.equal(false); + expect(await isRawApiAccessModeVisible()).to.equal(false); + }); + + it('should display the plugin settings when MCP is enabled with read-only API access', async function () { + await configureMcp(true, 'string:read'); + expect(await isRawApiAccessModeVisible()).to.equal(true); + expect(await page.$eval(rawApiAccessModeSelector, (el) => el.value)).to.equal('string:read'); expect(await page.screenshotSelector(settingsSelector)).to.matchImage('settings'); }); it('should show connect guidance for superusers when MCP is disabled', async function () { - await setMcpEnabled(false); + await configureMcp(false); await page.goto(connectUrl); await page.waitForNetworkIdle(); @@ -115,7 +152,7 @@ describe('McpServer', function () { }); it('should show contact-admin guidance for view users when MCP is disabled', async function () { - await setMcpEnabled(false); + await configureMcp(false); setViewUser(); await page.goto(connectUrl); @@ -134,10 +171,10 @@ describe('McpServer', function () { }); it('should display the connect page when MCP is enabled', async function () { - await setMcpEnabled(true); + await configureMcp(true, 'string:read'); await page.goto(connectUrl); await page.waitForNetworkIdle(); - await page.waitForSelector(`${connectSelector} .card-action`, { visible: true }); + await page.waitForSelector(connectSelector, { visible: true }); await page.mouse.move(-10, -10); expect(await page.screenshotSelector(connectSelector)).to.matchImage('connect_enabled'); diff --git a/tests/Unit/APITest.php b/tests/Unit/APITest.php index b737d19..454c084 100644 --- a/tests/Unit/APITest.php +++ b/tests/Unit/APITest.php @@ -301,6 +301,7 @@ private function createFactory(): McpServerFactory new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createMock(SystemSettings::class), ); } diff --git a/tests/Unit/McpServerFactoryTest.php b/tests/Unit/McpServerFactoryTest.php index ff6a215..a0fcad4 100644 --- a/tests/Unit/McpServerFactoryTest.php +++ b/tests/Unit/McpServerFactoryTest.php @@ -20,6 +20,7 @@ use Piwik\Plugin\Manager; use Piwik\Plugins\McpServer\McpServerFactory; use Piwik\Plugins\McpServer\Support\Logging\ToolCallParameterFormatter; +use Piwik\Plugins\McpServer\SystemSettings; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; use Psr\Container\ContainerInterface; @@ -56,6 +57,7 @@ public function testInitializeResponseHasExpectedServerInfoAndCapabilities(): vo new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $payload = McpTestHelper::makeInitializeRequest('init-1'); @@ -94,6 +96,7 @@ public function testToolCallLoggingEnabledInjectsObservedHandler(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -130,6 +133,7 @@ public function testStringOneConfigEnablesFullParameterLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -154,6 +158,7 @@ public function testBooleanTrueConfigEnablesToolCallLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -178,6 +183,7 @@ public function testToolCallLoggingDisabledSkipsObservedHandlerInjection(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -202,6 +208,7 @@ public function testStringZeroConfigDisablesToolCallLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -226,6 +233,7 @@ public function testStringTrueConfigDoesNotEnableToolCallLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -250,6 +258,7 @@ public function testToolCallLoggingMissingConfigSkipsObservedHandlerInjection(): new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -277,6 +286,7 @@ public function testConfiguredWarnLevelUsesWarning(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -304,6 +314,7 @@ public function testConfiguredErrorLevelUsesError(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -331,6 +342,7 @@ public function testConfiguredInfoLevelUsesInfo(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -357,6 +369,7 @@ public function testConfiguredVerboseLevelUsesDebug(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -386,6 +399,7 @@ public function testInvalidToolCallLogLevelFallsBackToDebug(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -399,14 +413,12 @@ public function testInvalidToolCallLogLevelFallsBackToDebug(): void public function testRawApiListToolIsHiddenWhenRawAccessModeIsMissingOrNone(): void { - Config::getInstance()->McpServer = []; - $toolsWhenMissing = $this->listToolNamesForCurrentConfig(); + $toolsWhenMissing = $this->listToolNamesForCurrentConfig('none'); self::assertNotContains('matomo_api_call', $toolsWhenMissing); self::assertNotContains('matomo_api_get', $toolsWhenMissing); self::assertNotContains('matomo_api_list', $toolsWhenMissing); - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'none']; - $toolsWhenNone = $this->listToolNamesForCurrentConfig(); + $toolsWhenNone = $this->listToolNamesForCurrentConfig('none'); self::assertNotContains('matomo_api_call', $toolsWhenNone); self::assertNotContains('matomo_api_get', $toolsWhenNone); self::assertNotContains('matomo_api_list', $toolsWhenNone); @@ -414,14 +426,12 @@ public function testRawApiListToolIsHiddenWhenRawAccessModeIsMissingOrNone(): vo public function testRawApiListToolIsVisibleWhenRawAccessModeIsReadOrFull(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; - $toolsWhenRead = $this->listToolNamesForCurrentConfig(); + $toolsWhenRead = $this->listToolNamesForCurrentConfig('read'); self::assertContains('matomo_api_call', $toolsWhenRead); self::assertContains('matomo_api_get', $toolsWhenRead); self::assertContains('matomo_api_list', $toolsWhenRead); - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; - $toolsWhenFull = $this->listToolNamesForCurrentConfig(); + $toolsWhenFull = $this->listToolNamesForCurrentConfig('full'); self::assertContains('matomo_api_call', $toolsWhenFull); self::assertContains('matomo_api_get', $toolsWhenFull); self::assertContains('matomo_api_list', $toolsWhenFull); @@ -429,8 +439,7 @@ public function testRawApiListToolIsVisibleWhenRawAccessModeIsReadOrFull(): void public function testRawApiGetToolHasFullAnnotationsWhenVisible(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; - $toolsWhenRead = $this->listToolsByNameForCurrentConfig(); + $toolsWhenRead = $this->listToolsByNameForCurrentConfig('read'); self::assertArrayHasKey('matomo_api_get', $toolsWhenRead); $toolWhenRead = $toolsWhenRead['matomo_api_get']; @@ -440,8 +449,7 @@ public function testRawApiGetToolHasFullAnnotationsWhenVisible(): void self::assertTrue($toolWhenRead->annotations->idempotentHint); self::assertFalse($toolWhenRead->annotations->openWorldHint); - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; - $toolsWhenFull = $this->listToolsByNameForCurrentConfig(); + $toolsWhenFull = $this->listToolsByNameForCurrentConfig('full'); self::assertArrayHasKey('matomo_api_get', $toolsWhenFull); $toolWhenFull = $toolsWhenFull['matomo_api_get']; @@ -454,8 +462,7 @@ public function testRawApiGetToolHasFullAnnotationsWhenVisible(): void public function testRawApiCallToolHasFullAnnotationsWhenVisible(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; - $toolsWhenRead = $this->listToolsByNameForCurrentConfig(); + $toolsWhenRead = $this->listToolsByNameForCurrentConfig('read'); self::assertArrayHasKey('matomo_api_call', $toolsWhenRead); $toolWhenRead = $toolsWhenRead['matomo_api_call']; @@ -465,8 +472,7 @@ public function testRawApiCallToolHasFullAnnotationsWhenVisible(): void self::assertFalse($toolWhenRead->annotations->idempotentHint); self::assertFalse($toolWhenRead->annotations->openWorldHint); - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; - $toolsWhenFull = $this->listToolsByNameForCurrentConfig(); + $toolsWhenFull = $this->listToolsByNameForCurrentConfig('full'); self::assertArrayHasKey('matomo_api_call', $toolsWhenFull); $toolWhenFull = $toolsWhenFull['matomo_api_call']; @@ -480,21 +486,22 @@ public function testRawApiCallToolHasFullAnnotationsWhenVisible(): void /** * @return list */ - private function listToolNamesForCurrentConfig(): array + private function listToolNamesForCurrentConfig(string $rawApiAccessMode = 'none'): array { - return array_keys($this->listToolsByNameForCurrentConfig()); + return array_keys($this->listToolsByNameForCurrentConfig($rawApiAccessMode)); } /** * @return array */ - private function listToolsByNameForCurrentConfig(): array + private function listToolsByNameForCurrentConfig(string $rawApiAccessMode = 'none'): array { $factory = new McpServerFactory( $this->createMock(LoggerInterface::class), new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub($rawApiAccessMode), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -511,4 +518,13 @@ private function listToolsByNameForCurrentConfig(): array return $toolsByName; } + + private function createSystemSettingsStub(string $rawApiAccessMode = 'none'): SystemSettings + { + $settings = $this->createMock(SystemSettings::class); + $settings->method('getRawApiAccessMode') + ->willReturn($rawApiAccessMode); + + return $settings; + } } diff --git a/tests/Unit/McpTools/ApiCallTest.php b/tests/Unit/McpTools/ApiCallTest.php index 3ebf933..8d9be87 100644 --- a/tests/Unit/McpTools/ApiCallTest.php +++ b/tests/Unit/McpTools/ApiCallTest.php @@ -12,11 +12,11 @@ namespace Piwik\Plugins\McpServer\tests\Unit\McpTools; use PHPUnit\Framework\TestCase; -use Piwik\Config; use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiCallQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiCallRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; use Piwik\Plugins\McpServer\McpTools\ApiCall; +use Piwik\Plugins\McpServer\SystemSettings; use stdClass; /** @@ -25,27 +25,8 @@ */ class ApiCallTest extends TestCase { - /** @var array|null */ - private ?array $originalMcpServerConfig = null; - - public function setUp(): void - { - parent::setUp(); - - $originalConfig = Config::getInstance()->McpServer ?? null; - $this->originalMcpServerConfig = is_array($originalConfig) ? $originalConfig : null; - } - - public function tearDown(): void - { - Config::getInstance()->McpServer = $this->originalMcpServerConfig; - - parent::tearDown(); - } - public function testCallUsesMethodSelector(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; $captured = new stdClass(); $captured->values = []; @@ -76,6 +57,7 @@ public function callApi( ); } }, + $this->createSystemSettingsStub('read'), ); $actual = $tool->call(method: ' API.getMatomoVersion '); @@ -100,7 +82,6 @@ public function callApi( public function testCallUsesSplitSelectorAndParameters(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; $captured = new stdClass(); $captured->values = []; @@ -131,6 +112,7 @@ public function callApi( ); } }, + $this->createSystemSettingsStub('full'), ); $actual = $tool->call( @@ -148,4 +130,13 @@ public function callApi( self::assertSame(' addUser ', $capturedValues['action']); self::assertSame(['userLogin' => 'alice'], $capturedValues['parameters']); } + + private function createSystemSettingsStub(string $rawApiAccessMode): SystemSettings + { + $settings = $this->createMock(SystemSettings::class); + $settings->method('getRawApiAccessMode') + ->willReturn($rawApiAccessMode); + + return $settings; + } } diff --git a/tests/Unit/McpTools/ApiGetTest.php b/tests/Unit/McpTools/ApiGetTest.php index 94d28fd..b63ba7f 100644 --- a/tests/Unit/McpTools/ApiGetTest.php +++ b/tests/Unit/McpTools/ApiGetTest.php @@ -12,11 +12,11 @@ namespace Piwik\Plugins\McpServer\tests\Unit\McpTools; use PHPUnit\Framework\TestCase; -use Piwik\Config; use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiMethodSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryQueryRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; use Piwik\Plugins\McpServer\McpTools\ApiGet; +use Piwik\Plugins\McpServer\SystemSettings; use stdClass; /** @@ -25,27 +25,8 @@ */ class ApiGetTest extends TestCase { - /** @var array|null */ - private ?array $originalMcpServerConfig = null; - - public function setUp(): void - { - parent::setUp(); - - $originalConfig = Config::getInstance()->McpServer ?? null; - $this->originalMcpServerConfig = is_array($originalConfig) ? $originalConfig : null; - } - - public function tearDown(): void - { - Config::getInstance()->McpServer = $this->originalMcpServerConfig; - - parent::tearDown(); - } - public function testGetReturnsRecordFromMethodSelector(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; $captured = new stdClass(); $captured->values = []; @@ -81,6 +62,7 @@ public function getApiMethodSummaryBySelector( ); } }, + $this->createSystemSettingsStub('read'), ); $actual = $tool->get(method: ' API.getMatomoVersion '); @@ -101,7 +83,6 @@ public function getApiMethodSummaryBySelector( public function testGetReturnsRecordFromModuleAndActionSelectors(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; $captured = new stdClass(); $captured->values = []; @@ -137,6 +118,7 @@ public function getApiMethodSummaryBySelector( ); } }, + $this->createSystemSettingsStub('full'), ); $actual = $tool->get(module: ' UsersManager ', action: ' addUser '); @@ -154,4 +136,13 @@ public function getApiMethodSummaryBySelector( self::assertSame(' UsersManager ', $capturedValues['module']); self::assertSame(' addUser ', $capturedValues['action']); } + + private function createSystemSettingsStub(string $rawApiAccessMode): SystemSettings + { + $settings = $this->createMock(SystemSettings::class); + $settings->method('getRawApiAccessMode') + ->willReturn($rawApiAccessMode); + + return $settings; + } } diff --git a/tests/Unit/McpTools/ApiListTest.php b/tests/Unit/McpTools/ApiListTest.php index 4f2f2e6..240f2a2 100644 --- a/tests/Unit/McpTools/ApiListTest.php +++ b/tests/Unit/McpTools/ApiListTest.php @@ -13,7 +13,6 @@ use Matomo\Dependencies\McpServer\Mcp\Exception\ToolCallException; use PHPUnit\Framework\TestCase; -use Piwik\Config; use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiMethodSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryQueryRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; @@ -21,6 +20,7 @@ use Piwik\Plugins\McpServer\Support\Pagination\ApiMethodsPagination; use Piwik\Plugins\McpServer\Support\Pagination\CursorPaginator; use Piwik\Plugins\McpServer\Support\Tooling\PaginatedCollectionResponder; +use Piwik\Plugins\McpServer\SystemSettings; /** * @group McpServer @@ -28,28 +28,8 @@ */ class ApiListTest extends TestCase { - /** @var array|null */ - private ?array $originalMcpServerConfig = null; - - public function setUp(): void - { - parent::setUp(); - - $originalConfig = Config::getInstance()->McpServer ?? null; - $this->originalMcpServerConfig = is_array($originalConfig) ? $originalConfig : null; - } - - public function tearDown(): void - { - Config::getInstance()->McpServer = $this->originalMcpServerConfig; - - parent::tearDown(); - } - public function testListReturnsReadOnlyMethodsInReadMode(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; - $tool = new ApiList( $this->createQueryServiceStub( static fn(ApiMethodSummaryQueryRecord $query): array => [ @@ -57,6 +37,7 @@ public function testListReturnsReadOnlyMethodsInReadMode(): void ] ), new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('read'), ); $actual = $tool->list(limit: 10, sort: ApiMethodsPagination::SORT_METHOD_ASC); @@ -78,7 +59,6 @@ public function testListReturnsReadOnlyMethodsInReadMode(): void public function testListReturnsAllMethodsInFullModeAndSupportsFilters(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; $capturedQuery = null; $tool = new ApiList( @@ -92,6 +72,7 @@ static function (ApiMethodSummaryQueryRecord $query) use (&$capturedQuery): arra }, ), new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), ); $actual = $tool->list(module: 'usersmanager', search: 'add', limit: 10); @@ -117,11 +98,10 @@ static function (ApiMethodSummaryQueryRecord $query) use (&$capturedQuery): arra public function testListRejectsInvalidCursor(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; - $tool = new ApiList( $this->createQueryServiceStub(static fn(ApiMethodSummaryQueryRecord $query): array => []), new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), ); $this->expectException(ToolCallException::class); @@ -132,11 +112,10 @@ public function testListRejectsInvalidCursor(): void public function testListSupportsPaginationAndSortOrdering(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; - $tool = new ApiList( $this->createExpandedQueryServiceStub(), new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), ); $firstPage = $tool->list(limit: 2, sort: ApiMethodsPagination::SORT_METHOD_ASC); @@ -177,17 +156,18 @@ public function testListSupportsPaginationAndSortOrdering(): void public function testListRejectsCursorWhenModeChanges(): void { + $rawApiAccessMode = 'read'; + $settings = $this->createMutableSystemSettingsStub($rawApiAccessMode); $tool = new ApiList( $this->createExpandedQueryServiceStub(), new PaginatedCollectionResponder(new CursorPaginator()), + $settings, ); - - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'read']; $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC); $cursor = $firstPage['next_cursor'] ?? null; self::assertIsString($cursor); - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; + $rawApiAccessMode = 'full'; $this->expectException(ToolCallException::class); $this->expectExceptionMessage('Invalid cursor.'); $tool->list(limit: 1, cursor: $cursor, sort: ApiMethodsPagination::SORT_METHOD_ASC); @@ -195,11 +175,10 @@ public function testListRejectsCursorWhenModeChanges(): void public function testListRejectsCursorWhenSearchChanges(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; - $tool = new ApiList( $this->createExpandedQueryServiceStub(), new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), ); $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC, search: 'get'); @@ -213,11 +192,10 @@ public function testListRejectsCursorWhenSearchChanges(): void public function testListRejectsCursorWhenModuleChanges(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; - $tool = new ApiList( $this->createExpandedQueryServiceStub(), new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), ); $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC, module: 'UsersManager'); @@ -231,11 +209,10 @@ public function testListRejectsCursorWhenModuleChanges(): void public function testListAcceptsCursorWhenEquivalentModuleNormalizationIsUsed(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; - $tool = new ApiList( $this->createExpandedQueryServiceStub(), new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), ); $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC, module: ' UsersManager '); @@ -257,11 +234,10 @@ public function testListAcceptsCursorWhenEquivalentModuleNormalizationIsUsed(): public function testListAcceptsCursorWhenEquivalentSearchNormalizationIsUsed(): void { - Config::getInstance()->McpServer = ['raw_api_access_mode' => 'full']; - $tool = new ApiList( $this->createExpandedQueryServiceStub(), new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), ); $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC, search: ' GET '); @@ -358,4 +334,24 @@ public function getApiMethodSummaryBySelector( } }; } + + private function createSystemSettingsStub(string $rawApiAccessMode): SystemSettings + { + $settings = $this->createMock(SystemSettings::class); + $settings->method('getRawApiAccessMode') + ->willReturn($rawApiAccessMode); + + return $settings; + } + + private function createMutableSystemSettingsStub(string &$rawApiAccessMode): SystemSettings + { + $settings = $this->createMock(SystemSettings::class); + $settings->method('getRawApiAccessMode') + ->willReturnCallback(static function () use (&$rawApiAccessMode): string { + return $rawApiAccessMode; + }); + + return $settings; + } } From 1ead20395ef9e7f590beaf782acf65b94fac349e Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Fri, 13 Mar 2026 18:07:17 +0100 Subject: [PATCH 06/15] Filter out some APIs from being used --- Services/Api/ApiMethodSummaryQueryService.php | 42 +++++-- Support/Access/RawApiAccessMode.php | 20 --- Support/Access/RawApiMethodPolicy.php | 64 ++++++++++ SystemSettings.php | 1 + docs/faq.md | 3 +- lang/en.json | 5 +- tests/Integration/McpTools/ApiCallTest.php | 100 +++++++++++++++ tests/Integration/McpTools/ApiGetTest.php | 114 +++++++++++++++++ tests/Integration/McpTools/ApiListTest.php | 117 ++++++++++++++++++ tests/Unit/McpServerFactoryTest.php | 4 +- .../Api/ApiMethodSummaryQueryServiceTest.php | 114 ++++++++++++++++- .../Support/Access/RawApiAccessModeTest.php | 10 -- .../Support/Access/RawApiMethodPolicyTest.php | 95 ++++++++++++++ 13 files changed, 641 insertions(+), 48 deletions(-) create mode 100644 Support/Access/RawApiMethodPolicy.php create mode 100644 tests/Unit/Support/Access/RawApiMethodPolicyTest.php diff --git a/Services/Api/ApiMethodSummaryQueryService.php b/Services/Api/ApiMethodSummaryQueryService.php index 96fb2a5..7ebc14d 100644 --- a/Services/Api/ApiMethodSummaryQueryService.php +++ b/Services/Api/ApiMethodSummaryQueryService.php @@ -18,7 +18,8 @@ use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiMethodSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryQueryRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; -use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; +use Piwik\Plugins\McpServer\Support\Access\RawApiMethodPolicy; +use ReflectionClass; final class ApiMethodSummaryQueryService implements ApiMethodSummaryQueryServiceInterface { @@ -57,6 +58,8 @@ public function getApiMethodSummaryBySelector( public function loadApiMethodSummaries(): array { // Mirrors API docs loading semantics by forcing API class registration through DocumentationGenerator. + // Proxy metadata remains the source of truth for @hide-style visibility; this service only adds the + // extra @internal filtering that DocumentationGenerator applies after loading metadata. new DocumentationGenerator(); $proxy = Proxy::getInstance(); @@ -72,6 +75,7 @@ public function loadApiMethodSummaries(): array foreach ($classInfo as $action => $methodInfo) { $isDeprecated = $proxy->isDeprecatedMethod((string) $className, (string) $action); $shouldInclude = $this->shouldIncludeMethodMetadataEntry( + $className, $action, $methodInfo, $isDeprecated, @@ -103,7 +107,7 @@ public function loadApiMethodSummaries(): array */ public function filterRecords(array $records, ApiMethodSummaryQueryRecord $query): array { - $records = $this->filterByAccessMode($records, $query->accessMode); + $records = $this->filterByAccessPolicy($records, $query->accessMode); $records = $this->filterByModule($records, $query->module); $records = $this->filterBySearch($records, $query->search); @@ -218,6 +222,7 @@ public function normalizeDefaultParameterValue(mixed $value): mixed * Public for testability and to share normalization contract across MCP tools. */ public function shouldIncludeMethodMetadataEntry( + mixed $className, mixed $action, mixed $methodInfo, bool $isDeprecated, @@ -230,23 +235,37 @@ public function shouldIncludeMethodMetadataEntry( return false; } - return is_array($methodInfo); + if (!is_array($methodInfo)) { + return false; + } + + if (!is_string($className) || !class_exists($className)) { + return true; + } + + $classReflection = new ReflectionClass($className); + if ($this->hasInternalAnnotation($classReflection->getDocComment())) { + return false; + } + + if (!$classReflection->hasMethod($action)) { + return true; + } + + return !$this->hasInternalAnnotation($classReflection->getMethod($action)->getDocComment()); } /** * @param array $records * @return array */ - private function filterByAccessMode(array $records, string $accessMode): array + private function filterByAccessPolicy(array $records, string $accessMode): array { - if ($accessMode === RawApiAccessMode::FULL) { - return $records; - } - return array_values(array_filter( $records, - static fn(ApiMethodSummaryRecord $record): bool => RawApiAccessMode::allowsMethodAction( + static fn(ApiMethodSummaryRecord $record): bool => RawApiMethodPolicy::allowsMethod( $accessMode, + $record->method, $record->action, ) )); @@ -288,4 +307,9 @@ private function normalizeSelectorValue(?string $value): string { return strtolower(trim((string) $value)); } + + private function hasInternalAnnotation(string|false $docComment): bool + { + return is_string($docComment) && str_contains($docComment, '@internal'); + } } diff --git a/Support/Access/RawApiAccessMode.php b/Support/Access/RawApiAccessMode.php index b60dcba..0dd10f6 100644 --- a/Support/Access/RawApiAccessMode.php +++ b/Support/Access/RawApiAccessMode.php @@ -41,24 +41,4 @@ public static function allowsToolRegistration(string $mode): bool { return $mode === self::READ || $mode === self::FULL; } - - public static function allowsMethodAction(string $mode, string $action): bool - { - if ($mode === self::FULL) { - return true; - } - - if ($mode !== self::READ) { - return false; - } - - return self::isReadAction($action); - } - - public static function isReadAction(string $action): bool - { - $normalizedAction = strtolower(trim($action)); - return str_starts_with($normalizedAction, 'get') - || str_starts_with($normalizedAction, 'is'); - } } diff --git a/Support/Access/RawApiMethodPolicy.php b/Support/Access/RawApiMethodPolicy.php new file mode 100644 index 0000000..495dc97 --- /dev/null +++ b/Support/Access/RawApiMethodPolicy.php @@ -0,0 +1,64 @@ + */ + private const DENIED_METHODS = [ + 'api.get' => true, + 'api.getbulkrequest' => true, + 'api.getmetadata' => true, + 'api.getprocessedreport' => true, + 'api.getreportmetadata' => true, + 'api.getrowevolution' => true, + 'imagegraph.get' => true, + 'insights.getinsights' => true, + 'insights.getmoversandshakers' => true, + 'treemapvisualization.gettreemapdata' => true, + ]; + + public static function allowsMethod(string $accessMode, string $method, string $action): bool + { + if (self::isDeniedMethod($method)) { + return false; + } + + if ($accessMode === RawApiAccessMode::FULL) { + return true; + } + + if ($accessMode !== RawApiAccessMode::READ) { + return false; + } + + return self::isReadHeuristicAction($action); + } + + public static function isDeniedMethod(string $method): bool + { + return isset(self::DENIED_METHODS[self::normalizeSelectorValue($method)]); + } + + public static function isReadHeuristicAction(string $action): bool + { + $normalizedAction = self::normalizeSelectorValue($action); + + return str_starts_with($normalizedAction, 'get') + || str_starts_with($normalizedAction, 'is'); + } + + private static function normalizeSelectorValue(?string $value): string + { + return strtolower(trim((string) $value)); + } +} diff --git a/SystemSettings.php b/SystemSettings.php index 776e181..2ba6a7f 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -55,6 +55,7 @@ function (FieldConfig $field) { $field->title = Piwik::translate('McpServer_RawApiAccessModeTitle'); $field->inlineHelp = implode('

', [ Piwik::translate('McpServer_RawApiAccessModeHelpPurpose'), + Piwik::translate('McpServer_RawApiAccessModeHelpReadFallback'), Piwik::translate('McpServer_RawApiAccessModeHelpDataScope'), Piwik::translate('McpServer_RawApiAccessModeHelpDestructive'), Piwik::translate('McpServer_RawApiAccessModeHelpPolicy'), diff --git a/docs/faq.md b/docs/faq.md index 6a9445b..26640bd 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -31,8 +31,9 @@ Configure raw Matomo API tool access in **Administration -> System -> Plugin Set - Use the **Raw Matomo API tool access** setting to control visibility for `matomo_api_list`, `matomo_api_get`, and `matomo_api_call`. - `Disabled`: hides `matomo_api_list`, `matomo_api_get`, and `matomo_api_call` (default). -- `Read only`: shows the raw API tools and allows only the current read-only heuristic (`get`/`is` methods). +- `Best-effort read filtering`: shows the raw API tools, blocks known risky proxy-like APIs, and otherwise falls back to method-name filtering (`get`/`is`) for discovered methods. - `Full API access`: shows the raw API tools and allows direct API calls, including state-changing or destructive methods. +- Best-effort read filtering is not a strict security boundary for unknown plugin APIs. - Direct API access can expose raw or personal data depending on enabled Matomo features. Review privacy and security requirements before enabling it, and consult your DPO or compliance owner when needed. ## Enabling MCP diff --git a/lang/en.json b/lang/en.json index 8d45db8..f896790 100644 --- a/lang/en.json +++ b/lang/en.json @@ -35,10 +35,11 @@ "RawApiAccessModeHelpDataScope": "Direct Matomo API access can expose the same data available through the Matomo user interface or Reporting API, including raw or personal data when features such as the Visitor Log are enabled.", "RawApiAccessModeHelpDestructive": "Full API access can execute state-changing or destructive API methods, including actions that modify configuration or delete data.", "RawApiAccessModeHelpPolicy": "Before enabling direct API access, ensure this complies with your organization's privacy and security policies and applicable regulations. You may need approval from your data protection officer (DPO) or another compliance owner.", - "RawApiAccessModeHelpPurpose": "Control whether the raw Matomo API tools are hidden, limited to read-only methods, or allowed to execute direct API calls.", + "RawApiAccessModeHelpPurpose": "Control whether the raw Matomo API tools are hidden, use best-effort read filtering, or allow direct API calls.", + "RawApiAccessModeHelpReadFallback": "Best-effort read filtering is not a strict security boundary. It blocks known risky APIs and otherwise falls back to method-name filtering for discovered API methods.", "RawApiAccessModeOptionFull": "Full API access", "RawApiAccessModeOptionNone": "Disabled", - "RawApiAccessModeOptionRead": "Read only", + "RawApiAccessModeOptionRead": "Best-effort read filtering", "RawApiAccessModeTitle": "Raw Matomo API tool access" } } diff --git a/tests/Integration/McpTools/ApiCallTest.php b/tests/Integration/McpTools/ApiCallTest.php index e402d3f..04bdcdd 100644 --- a/tests/Integration/McpTools/ApiCallTest.php +++ b/tests/Integration/McpTools/ApiCallTest.php @@ -163,6 +163,106 @@ public function testReadModeRejectsWriteOnlyMethod(): void ); } + public function testReadModeRejectsNonHeuristicMethod(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['method' => 'UsersManager.hasSuperUserAccess'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testFullModeCallsKnownNonHeuristicMethod(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['method' => 'UsersManager.hasSuperUserAccess'], + __METHOD__, + ); + + $resolvedMethod = $content['resolvedMethod'] ?? null; + self::assertIsArray($resolvedMethod); + self::assertSame('UsersManager.hasSuperUserAccess', $resolvedMethod['method'] ?? null); + self::assertIsBool($content['result'] ?? null); + } + + public function testReadModeRejectsBlockedProxyLikeMethod(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['method' => 'API.getBulkRequest'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testReadModeRejectsGetMetadata(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['method' => 'API.getMetadata'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testReadModeRejectsGetReportMetadata(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['method' => 'API.getReportMetadata'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testFullModeRejectsBlockedProxyLikeMethodBySplitSelector(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['module' => 'Insights', 'action' => 'getInsights'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + public function testFullModeAttemptsMutatingMethodCall(): void { McpTestHelper::setRawApiAccessMode('full'); diff --git a/tests/Integration/McpTools/ApiGetTest.php b/tests/Integration/McpTools/ApiGetTest.php index 5a97d84..89f6324 100644 --- a/tests/Integration/McpTools/ApiGetTest.php +++ b/tests/Integration/McpTools/ApiGetTest.php @@ -94,6 +94,120 @@ public function testReadModeRejectsWriteOnlyMethod(): void ); } + public function testReadModeRejectsNonHeuristicMethod(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['method' => 'UsersManager.hasSuperUserAccess'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testFullModeReturnsKnownNonHeuristicMethod(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['method' => 'UsersManager.hasSuperUserAccess'], + __METHOD__, + ); + + self::assertSame('UsersManager.hasSuperUserAccess', $content['method'] ?? null); + } + + public function testReadModeRejectsBlockedProxyLikeMethod(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['method' => 'API.getProcessedReport'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testFullModeRejectsBlockedProxyLikeMethodBySplitSelector(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['module' => 'TreemapVisualization', 'action' => 'getTreemapData'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testReadModeRejectsGetMetadata(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['method' => 'API.getMetadata'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testReadModeRejectsGetReportMetadata(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['method' => 'API.getReportMetadata'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testReadModeKeepsGetSuggestedValuesForSegmentAvailable(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['method' => 'API.getSuggestedValuesForSegment'], + __METHOD__, + ); + + self::assertSame('API.getSuggestedValuesForSegment', $content['method'] ?? null); + } + public function testRejectsIncompleteSplitSelectorAtSchemaLevel(): void { McpTestHelper::setRawApiAccessMode('full'); diff --git a/tests/Integration/McpTools/ApiListTest.php b/tests/Integration/McpTools/ApiListTest.php index bf1ddec..df3f60e 100644 --- a/tests/Integration/McpTools/ApiListTest.php +++ b/tests/Integration/McpTools/ApiListTest.php @@ -102,6 +102,99 @@ public function testFullModeCanReturnMutatingActions(): void self::assertTrue($foundMutatingAction, 'Expected at least one non-read action in full mode.'); } + public function testReadModeHidesBlockedProxyLikeMethods(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $methods = $this->listMethodsForCurrentConfig(500); + + self::assertContains('API.getSuggestedValuesForSegment', $methods); + self::assertNotContains('API.get', $methods); + self::assertNotContains('API.getBulkRequest', $methods); + self::assertNotContains('API.getMetadata', $methods); + self::assertNotContains('API.getProcessedReport', $methods); + self::assertNotContains('API.getReportMetadata', $methods); + self::assertNotContains('API.getRowEvolution', $methods); + self::assertNotContains('ImageGraph.get', $methods); + self::assertNotContains('Insights.getInsights', $methods); + self::assertNotContains('Insights.getMoversAndShakers', $methods); + self::assertNotContains('TreemapVisualization.getTreemapData', $methods); + } + + public function testReadModeUsesHeuristicFallbackForUnknownMethods(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $methods = $this->listMethodsForCurrentConfig(500); + + self::assertContains('UsersManager.getUsers', $methods); + self::assertNotContains('UsersManager.hasSuperUserAccess', $methods); + } + + public function testFullModeHidesInternalAnnotatedMethods(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $methods = $this->listMethodsForCurrentConfig(500); + + self::assertNotContains('SitesManager.getMessagesToWarnOnSiteRemoval', $methods); + self::assertNotContains('JsTrackerInstallCheck.wasJsTrackerInstallTestSuccessful', $methods); + self::assertNotContains('JsTrackerInstallCheck.initiateJsTrackerInstallTest', $methods); + } + + public function testFullModeKeepsHideExceptForSuperUserMethodsVisibleForSuperUser(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $methods = $this->listMethodsForCurrentConfig(500); + + self::assertContains('CoreAdminHome.runScheduledTasks', $methods); + } + + public function testFullModeAllowsNonHeuristicMethodsWhenTheyAreNotDenied(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['search' => 'hassuperuseraccess', 'limit' => 50], + __METHOD__, + ); + $methodsData = $content['methods'] ?? null; + self::assertIsArray($methodsData); + + $methods = array_map( + static fn(array $row): string => (string) ($row['method'] ?? ''), + $methodsData, + ); + + self::assertContains('UsersManager.hasSuperUserAccess', $methods); + } + + public function testFullModeHidesBlockedProxyLikeMethods(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $methods = $this->listMethodsForCurrentConfig(500); + + self::assertContains('API.getSuggestedValuesForSegment', $methods); + self::assertNotContains('API.get', $methods); + self::assertNotContains('API.getBulkRequest', $methods); + self::assertNotContains('API.getMetadata', $methods); + self::assertNotContains('API.getProcessedReport', $methods); + self::assertNotContains('API.getReportMetadata', $methods); + self::assertNotContains('API.getRowEvolution', $methods); + self::assertNotContains('ImageGraph.get', $methods); + self::assertNotContains('Insights.getInsights', $methods); + self::assertNotContains('Insights.getMoversAndShakers', $methods); + self::assertNotContains('TreemapVisualization.getTreemapData', $methods); + } + public function testReturnsPagedResultsWithCursor(): void { McpTestHelper::setRawApiAccessMode('read'); @@ -285,4 +378,28 @@ private function listToolNamesForCurrentConfig(): array return array_values(array_map(static fn($tool) => $tool->name, $result->tools)); } + + /** + * @return list + */ + private function listMethodsForCurrentConfig(int $limit): array + { + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['limit' => $limit], + __METHOD__, + ); + + $methods = $content['methods'] ?? null; + self::assertIsArray($methods); + + return array_values(array_map( + static fn(array $row): string => (string) ($row['method'] ?? ''), + $methods, + )); + } } diff --git a/tests/Unit/McpServerFactoryTest.php b/tests/Unit/McpServerFactoryTest.php index a0fcad4..05365ee 100644 --- a/tests/Unit/McpServerFactoryTest.php +++ b/tests/Unit/McpServerFactoryTest.php @@ -411,14 +411,14 @@ public function testInvalidToolCallLogLevelFallsBackToDebug(): void self::assertSame(JsonRpcError::METHOD_NOT_FOUND, $message->code); } - public function testRawApiListToolIsHiddenWhenRawAccessModeIsMissingOrNone(): void + public function testRawApiListToolIsHiddenWhenRawAccessModeIsNoneOrInvalid(): void { $toolsWhenMissing = $this->listToolNamesForCurrentConfig('none'); self::assertNotContains('matomo_api_call', $toolsWhenMissing); self::assertNotContains('matomo_api_get', $toolsWhenMissing); self::assertNotContains('matomo_api_list', $toolsWhenMissing); - $toolsWhenNone = $this->listToolNamesForCurrentConfig('none'); + $toolsWhenNone = $this->listToolNamesForCurrentConfig('invalid'); self::assertNotContains('matomo_api_call', $toolsWhenNone); self::assertNotContains('matomo_api_get', $toolsWhenNone); self::assertNotContains('matomo_api_list', $toolsWhenNone); diff --git a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php index f6ebbb2..d8bc9e1 100644 --- a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php +++ b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php @@ -27,10 +27,46 @@ public function testShouldIncludeMethodMetadataEntryRejectsDocumentationAndDepre { $service = new ApiMethodSummaryQueryService(); - self::assertFalse($service->shouldIncludeMethodMetadataEntry('__documentation', [], false)); - self::assertFalse($service->shouldIncludeMethodMetadataEntry('getUsers', [], true)); - self::assertFalse($service->shouldIncludeMethodMetadataEntry('getUsers', 'invalid', false)); - self::assertTrue($service->shouldIncludeMethodMetadataEntry('getUsers', [], false)); + self::assertFalse($service->shouldIncludeMethodMetadataEntry(self::class, '__documentation', [], false)); + self::assertFalse($service->shouldIncludeMethodMetadataEntry(self::class, 'getUsers', [], true)); + self::assertFalse($service->shouldIncludeMethodMetadataEntry(self::class, 'getUsers', 'invalid', false)); + self::assertTrue($service->shouldIncludeMethodMetadataEntry(self::class, 'getUsers', [], false)); + } + + public function testShouldIncludeMethodMetadataEntryRejectsMethodLevelInternalAnnotation(): void + { + $service = new ApiMethodSummaryQueryService(); + + self::assertFalse($service->shouldIncludeMethodMetadataEntry( + InternalMethodFixture::class, + 'hiddenMethod', + [], + false, + )); + } + + public function testShouldIncludeMethodMetadataEntryRejectsClassLevelInternalAnnotation(): void + { + $service = new ApiMethodSummaryQueryService(); + + self::assertFalse($service->shouldIncludeMethodMetadataEntry( + InternalClassFixture::class, + 'visibleMethod', + [], + false, + )); + } + + public function testShouldIncludeMethodMetadataEntryAllowsMissingClassMetadata(): void + { + $service = new ApiMethodSummaryQueryService(); + + self::assertTrue($service->shouldIncludeMethodMetadataEntry( + 'Piwik\\Plugins\\Missing\\API', + 'getUsers', + [], + false, + )); } public function testNormalizeParameterMetadataHandlesNoDefaultValueAsRequired(): void @@ -133,6 +169,56 @@ public function testFilterRecordsAppliesReadAccessMode(): void ); } + public function testFilterRecordsUsesFullModeForNonHeuristicReadMethod(): void + { + $service = new ApiMethodSummaryQueryService(); + + $readRecords = $service->filterRecords( + [new ApiMethodSummaryRecord('UsersManager', 'hasSuperUserAccess', 'UsersManager.hasSuperUserAccess', [])], + ApiMethodSummaryQueryRecord::fromInputs('read'), + ); + $fullRecords = $service->filterRecords( + [new ApiMethodSummaryRecord('UsersManager', 'hasSuperUserAccess', 'UsersManager.hasSuperUserAccess', [])], + ApiMethodSummaryQueryRecord::fromInputs('full'), + ); + + self::assertSame([], $readRecords); + self::assertSame( + ['UsersManager.hasSuperUserAccess'], + array_values(array_map(static fn(ApiMethodSummaryRecord $record): string => $record->method, $fullRecords)), + ); + } + + public function testFilterRecordsRemovesBlockedProxyLikeMethodsInFullMode(): void + { + $service = new ApiMethodSummaryQueryService(); + + $records = $service->filterRecords( + [ + new ApiMethodSummaryRecord('API', 'getProcessedReport', 'API.getProcessedReport', []), + new ApiMethodSummaryRecord('API', 'getMetadata', 'API.getMetadata', []), + new ApiMethodSummaryRecord( + 'API', + 'getSuggestedValuesForSegment', + 'API.getSuggestedValuesForSegment', + [], + ), + new ApiMethodSummaryRecord( + 'TreemapVisualization', + 'getTreemapData', + 'TreemapVisualization.getTreemapData', + [], + ), + ], + ApiMethodSummaryQueryRecord::fromInputs('full'), + ); + + self::assertSame( + ['API.getSuggestedValuesForSegment'], + array_values(array_map(static fn(ApiMethodSummaryRecord $record): string => $record->method, $records)), + ); + } + public function testFilterRecordsAppliesCaseInsensitiveExactModuleFilter(): void { $service = new ApiMethodSummaryQueryService(); @@ -234,3 +320,23 @@ private function createMethodRecords(): array ]; } } + +class InternalMethodFixture +{ + /** + * @internal + */ + public function hiddenMethod(): void + { + } +} + +/** + * @internal + */ +class InternalClassFixture +{ + public function visibleMethod(): void + { + } +} diff --git a/tests/Unit/Support/Access/RawApiAccessModeTest.php b/tests/Unit/Support/Access/RawApiAccessModeTest.php index 5f414bf..531fceb 100644 --- a/tests/Unit/Support/Access/RawApiAccessModeTest.php +++ b/tests/Unit/Support/Access/RawApiAccessModeTest.php @@ -33,14 +33,4 @@ public function testNormalizeAcceptsSupportedValuesCaseInsensitively(): void self::assertSame(RawApiAccessMode::READ, RawApiAccessMode::normalize('Read')); self::assertSame(RawApiAccessMode::FULL, RawApiAccessMode::normalize('FULL')); } - - public function testAllowsMethodActionRespectsReadAndFullModes(): void - { - self::assertTrue(RawApiAccessMode::allowsMethodAction(RawApiAccessMode::FULL, 'deleteUser')); - - self::assertTrue(RawApiAccessMode::allowsMethodAction(RawApiAccessMode::READ, 'getUsers')); - self::assertTrue(RawApiAccessMode::allowsMethodAction(RawApiAccessMode::READ, 'isGoalEnabled')); - self::assertFalse(RawApiAccessMode::allowsMethodAction(RawApiAccessMode::READ, 'addUser')); - self::assertFalse(RawApiAccessMode::allowsMethodAction(RawApiAccessMode::NONE, 'getUsers')); - } } diff --git a/tests/Unit/Support/Access/RawApiMethodPolicyTest.php b/tests/Unit/Support/Access/RawApiMethodPolicyTest.php new file mode 100644 index 0000000..5788880 --- /dev/null +++ b/tests/Unit/Support/Access/RawApiMethodPolicyTest.php @@ -0,0 +1,95 @@ + Date: Wed, 1 Apr 2026 20:40:26 +0200 Subject: [PATCH 07/15] Implement CRUD-style API classification --- .../Api/ApiMethodSummaryQueryRecord.php | 12 +- .../Records/Api/ApiMethodSummaryRecord.php | 7 + McpTools/ApiList.php | 3 + Schemas/Api/ApiListToolInputSchema.php | 12 ++ .../Api/ApiMethodSummaryToolOutputSchema.php | 20 +- Services/Api/ApiMethodSummaryQueryService.php | 32 ++++ Support/Api/ApiMethodOperationClassifier.php | 167 ++++++++++++++++ tests/Integration/McpTools/ApiCallTest.php | 7 + tests/Integration/McpTools/ApiGetTest.php | 6 + tests/Integration/McpTools/ApiListTest.php | 133 +++++++++++++ tests/Unit/McpTools/ApiCallTest.php | 22 ++- tests/Unit/McpTools/ApiGetTest.php | 9 + tests/Unit/McpTools/ApiListTest.php | 181 ++++++++++++++++-- .../Api/ApiMethodSummaryQueryServiceTest.php | 103 +++++++++- .../SegmentDetailQueryServiceTest.php | 37 ++++ .../Api/ApiMethodOperationClassifierTest.php | 154 +++++++++++++++ 16 files changed, 878 insertions(+), 27 deletions(-) create mode 100644 Support/Api/ApiMethodOperationClassifier.php create mode 100644 tests/Unit/Support/Api/ApiMethodOperationClassifierTest.php diff --git a/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php b/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php index 682e848..7c8fd4e 100644 --- a/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php +++ b/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php @@ -11,21 +11,29 @@ namespace Piwik\Plugins\McpServer\Contracts\Records\Api; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; + final class ApiMethodSummaryQueryRecord { public function __construct( public readonly string $accessMode, public readonly string $module, public readonly string $search, + public readonly string $operationCategory, ) { } - public static function fromInputs(string $accessMode, ?string $module = null, ?string $search = null): self - { + public static function fromInputs( + string $accessMode, + ?string $module = null, + ?string $search = null, + ?string $operationCategory = null, + ): self { return new self( accessMode: trim($accessMode), module: strtolower(trim((string) $module)), search: strtolower(trim((string) $search)), + operationCategory: ApiMethodOperationClassifier::normalizeCategory($operationCategory), ); } } diff --git a/Contracts/Records/Api/ApiMethodSummaryRecord.php b/Contracts/Records/Api/ApiMethodSummaryRecord.php index 295b6d3..b1d9174 100644 --- a/Contracts/Records/Api/ApiMethodSummaryRecord.php +++ b/Contracts/Records/Api/ApiMethodSummaryRecord.php @@ -25,6 +25,7 @@ * action: string, * method: string, * parameters: list, + * operationCategory: string|null, * } */ final class ApiMethodSummaryRecord @@ -35,6 +36,11 @@ public function __construct( public readonly string $action, public readonly string $method, public readonly array $parameters, + public readonly ?string $operationCategory = null, + // Internal-only metadata for access-policy decisions and debugging. + // Not intended to be exposed through public MCP tool responses. + public readonly string $classificationConfidence = 'low', + public readonly string $classificationReason = 'not-classified', ) { } @@ -48,6 +54,7 @@ public function toArray(): array 'action' => $this->action, 'method' => $this->method, 'parameters' => $this->parameters, + 'operationCategory' => $this->operationCategory, ]; } } diff --git a/McpTools/ApiList.php b/McpTools/ApiList.php index 1aa8942..801ef8f 100644 --- a/McpTools/ApiList.php +++ b/McpTools/ApiList.php @@ -47,16 +47,19 @@ public function list( ?string $sort = null, ?string $module = null, ?string $search = null, + ?string $category = null, ): array { $query = ApiMethodSummaryQueryRecord::fromInputs( $this->systemSettings->getRawApiAccessMode(), $module, $search, + $category, ); $cursorContext = CursorContextBuilder::forTool(self::TOOL_NAME, [ 'module' => $query->module, 'search' => $query->search, + 'category' => $query->operationCategory, 'mode' => $query->accessMode, ]); diff --git a/Schemas/Api/ApiListToolInputSchema.php b/Schemas/Api/ApiListToolInputSchema.php index e2b5313..8d99528 100644 --- a/Schemas/Api/ApiListToolInputSchema.php +++ b/Schemas/Api/ApiListToolInputSchema.php @@ -11,6 +11,7 @@ namespace Piwik\Plugins\McpServer\Schemas\Api; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; use Piwik\Plugins\McpServer\Support\Pagination\ApiMethodsPagination; final class ApiListToolInputSchema @@ -49,6 +50,17 @@ final class ApiListToolInputSchema 'description' => 'Optional case-insensitive substring filter on the ' . 'composite Module.action method name.', ], + 'category' => [ + 'type' => 'string', + 'enum' => [ + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CATEGORY_UPDATE, + ApiMethodOperationClassifier::CATEGORY_DELETE, + ApiMethodOperationClassifier::CATEGORY_UNCATEGORIZED, + ], + 'description' => 'Optional CRUD-style or uncategorized filter for heuristically classified methods.', + ], ], 'additionalProperties' => false, ]; diff --git a/Schemas/Api/ApiMethodSummaryToolOutputSchema.php b/Schemas/Api/ApiMethodSummaryToolOutputSchema.php index 00c5700..f591d72 100644 --- a/Schemas/Api/ApiMethodSummaryToolOutputSchema.php +++ b/Schemas/Api/ApiMethodSummaryToolOutputSchema.php @@ -11,6 +11,8 @@ namespace Piwik\Plugins\McpServer\Schemas\Api; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; + final class ApiMethodSummaryToolOutputSchema { public const PARAMETER = [ @@ -37,8 +39,24 @@ final class ApiMethodSummaryToolOutputSchema 'type' => 'array', 'items' => self::PARAMETER, ], + 'operationCategory' => [ + 'type' => ['string', 'null'], + 'enum' => [ + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CATEGORY_UPDATE, + ApiMethodOperationClassifier::CATEGORY_DELETE, + null, + ], + ], + ], + 'required' => [ + 'module', + 'action', + 'method', + 'parameters', + 'operationCategory', ], - 'required' => ['module', 'action', 'method', 'parameters'], 'additionalProperties' => false, ]; diff --git a/Services/Api/ApiMethodSummaryQueryService.php b/Services/Api/ApiMethodSummaryQueryService.php index 7ebc14d..f910892 100644 --- a/Services/Api/ApiMethodSummaryQueryService.php +++ b/Services/Api/ApiMethodSummaryQueryService.php @@ -19,6 +19,7 @@ use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryQueryRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; use Piwik\Plugins\McpServer\Support\Access\RawApiMethodPolicy; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; use ReflectionClass; final class ApiMethodSummaryQueryService implements ApiMethodSummaryQueryServiceInterface @@ -86,12 +87,19 @@ public function loadApiMethodSummaries(): array /** @var array $methodInfo */ $parameters = $this->normalizeParameterMetadata($methodInfo['parameters'] ?? null); + $classification = ApiMethodOperationClassifier::classify( + $module . '.' . (string) $action, + (string) $action, + ); $records[] = new ApiMethodSummaryRecord( module: $module, action: (string) $action, method: $module . '.' . $action, parameters: $parameters, + operationCategory: $classification['operationCategory'], + classificationConfidence: $classification['classificationConfidence'], + classificationReason: $classification['classificationReason'], ); } } @@ -110,6 +118,7 @@ public function filterRecords(array $records, ApiMethodSummaryQueryRecord $query $records = $this->filterByAccessPolicy($records, $query->accessMode); $records = $this->filterByModule($records, $query->module); $records = $this->filterBySearch($records, $query->search); + $records = $this->filterByOperationCategory($records, $query->operationCategory); return $records; } @@ -303,6 +312,29 @@ private function filterBySearch(array $records, string $searchTerm): array )); } + /** + * @param array $records + * @return array + */ + private function filterByOperationCategory(array $records, string $operationCategory): array + { + if ($operationCategory === '') { + return $records; + } + + if ($operationCategory === ApiMethodOperationClassifier::CATEGORY_UNCATEGORIZED) { + return array_values(array_filter( + $records, + static fn(ApiMethodSummaryRecord $record): bool => $record->operationCategory === null, + )); + } + + return array_values(array_filter( + $records, + static fn(ApiMethodSummaryRecord $record): bool => $record->operationCategory === $operationCategory, + )); + } + private function normalizeSelectorValue(?string $value): string { return strtolower(trim((string) $value)); diff --git a/Support/Api/ApiMethodOperationClassifier.php b/Support/Api/ApiMethodOperationClassifier.php new file mode 100644 index 0000000..9622e71 --- /dev/null +++ b/Support/Api/ApiMethodOperationClassifier.php @@ -0,0 +1,167 @@ + */ + private const HIGH_CONFIDENCE_PREFIXES = [ + 'get' => true, + 'is' => true, + 'add' => true, + 'create' => true, + 'update' => true, + 'delete' => true, + 'remove' => true, + ]; + + /** @var array */ + private const MEDIUM_CONFIDENCE_PREFIX_TO_CATEGORY = [ + 'has' => self::CATEGORY_READ, + 'can' => self::CATEGORY_READ, + 'was' => self::CATEGORY_READ, + 'are' => self::CATEGORY_READ, + 'does' => self::CATEGORY_READ, + 'uses' => self::CATEGORY_READ, + 'check' => self::CATEGORY_READ, + 'search' => self::CATEGORY_READ, + 'find' => self::CATEGORY_READ, + 'detect' => self::CATEGORY_READ, + 'validate' => self::CATEGORY_READ, + 'invite' => self::CATEGORY_CREATE, + 'initiate' => self::CATEGORY_CREATE, + 'set' => self::CATEGORY_UPDATE, + 'save' => self::CATEGORY_UPDATE, + 'enable' => self::CATEGORY_UPDATE, + 'disable' => self::CATEGORY_UPDATE, + 'change' => self::CATEGORY_UPDATE, + 'configure' => self::CATEGORY_UPDATE, + 'copy' => self::CATEGORY_UPDATE, + 'star' => self::CATEGORY_UPDATE, + 'unstar' => self::CATEGORY_UPDATE, + 'archive' => self::CATEGORY_UPDATE, + 'mark' => self::CATEGORY_UPDATE, + 'clear' => self::CATEGORY_UPDATE, + 'regenerate' => self::CATEGORY_UPDATE, + 'edit' => self::CATEGORY_UPDATE, + ]; + + public static function normalizeCategory(?string $category): string + { + $normalized = strtolower(trim((string) $category)); + + if ( + $normalized !== self::CATEGORY_READ + && $normalized !== self::CATEGORY_CREATE + && $normalized !== self::CATEGORY_UPDATE + && $normalized !== self::CATEGORY_DELETE + && $normalized !== self::CATEGORY_UNCATEGORIZED + ) { + return ''; + } + + return $normalized; + } + + /** + * @return array{ + * operationCategory: string|null, + * classificationConfidence: string, + * classificationReason: string, + * } + */ + public static function classify(string $method, string $action): array + { + $prefix = self::extractActionPrefix($action); + if ($prefix === '') { + return self::unclassified('missing-action-prefix'); + } + + if (isset(self::HIGH_CONFIDENCE_PREFIXES[$prefix])) { + return [ + 'operationCategory' => self::mapHighConfidencePrefixToCategory($prefix), + 'classificationConfidence' => self::CONFIDENCE_HIGH, + 'classificationReason' => 'action-prefix:' . $prefix, + ]; + } + + if (isset(self::MEDIUM_CONFIDENCE_PREFIX_TO_CATEGORY[$prefix])) { + return [ + 'operationCategory' => self::MEDIUM_CONFIDENCE_PREFIX_TO_CATEGORY[$prefix], + 'classificationConfidence' => self::CONFIDENCE_MEDIUM, + 'classificationReason' => 'action-prefix:' . $prefix, + ]; + } + + if (self::hasReadExistsSuffix($action)) { + return [ + 'operationCategory' => self::CATEGORY_READ, + 'classificationConfidence' => self::CONFIDENCE_MEDIUM, + 'classificationReason' => 'action-suffix:exists', + ]; + } + + return self::unclassified('unsupported-action-prefix:' . $prefix); + } + + private static function extractActionPrefix(string $action): string + { + $normalizedAction = trim($action); + if (preg_match('/^([a-z]+)/', $normalizedAction, $matches) !== 1) { + return ''; + } + + return $matches[1]; + } + + private static function mapHighConfidencePrefixToCategory(string $prefix): string + { + return match ($prefix) { + 'get', 'is' => self::CATEGORY_READ, + 'add', 'create' => self::CATEGORY_CREATE, + 'update' => self::CATEGORY_UPDATE, + 'delete', 'remove' => self::CATEGORY_DELETE, + default => throw new \InvalidArgumentException('Unsupported high-confidence prefix.'), + }; + } + + private static function hasReadExistsSuffix(string $action): bool + { + return str_ends_with(trim($action), 'Exists'); + } + + /** + * @return array{ + * operationCategory: null, + * classificationConfidence: string, + * classificationReason: string, + * } + */ + private static function unclassified(string $reason): array + { + return [ + 'operationCategory' => null, + 'classificationConfidence' => self::CONFIDENCE_LOW, + 'classificationReason' => $reason, + ]; + } +} diff --git a/tests/Integration/McpTools/ApiCallTest.php b/tests/Integration/McpTools/ApiCallTest.php index 04bdcdd..a9c6ca5 100644 --- a/tests/Integration/McpTools/ApiCallTest.php +++ b/tests/Integration/McpTools/ApiCallTest.php @@ -86,6 +86,9 @@ public function testReadModeCallsKnownReadMethodByMethodSelector(): void self::assertSame('API', $resolvedMethod['module'] ?? null); self::assertSame('getMatomoVersion', $resolvedMethod['action'] ?? null); self::assertSame('API.getMatomoVersion', $resolvedMethod['method'] ?? null); + self::assertSame('read', $resolvedMethod['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $resolvedMethod); + self::assertArrayNotHasKey('classificationReason', $resolvedMethod); self::assertArrayHasKey('result', $content); self::assertIsString($content['result']); self::assertNotSame('', $content['result']); @@ -108,6 +111,7 @@ public function testReadModeCallsKnownReadMethodByModuleAndActionSelector(): voi $resolvedMethod = $content['resolvedMethod'] ?? null; self::assertIsArray($resolvedMethod); self::assertSame('API.getMatomoVersion', $resolvedMethod['method'] ?? null); + self::assertSame('read', $resolvedMethod['operationCategory'] ?? null); self::assertArrayHasKey('result', $content); self::assertIsString($content['result']); } @@ -196,6 +200,9 @@ public function testFullModeCallsKnownNonHeuristicMethod(): void $resolvedMethod = $content['resolvedMethod'] ?? null; self::assertIsArray($resolvedMethod); self::assertSame('UsersManager.hasSuperUserAccess', $resolvedMethod['method'] ?? null); + self::assertSame('read', $resolvedMethod['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $resolvedMethod); + self::assertArrayNotHasKey('classificationReason', $resolvedMethod); self::assertIsBool($content['result'] ?? null); } diff --git a/tests/Integration/McpTools/ApiGetTest.php b/tests/Integration/McpTools/ApiGetTest.php index 89f6324..c881ff3 100644 --- a/tests/Integration/McpTools/ApiGetTest.php +++ b/tests/Integration/McpTools/ApiGetTest.php @@ -56,6 +56,9 @@ public function testReadModeReturnsKnownReadMethodByMethodSelector(): void self::assertSame('getMatomoVersion', $content['action'] ?? null); self::assertSame('API.getMatomoVersion', $content['method'] ?? null); self::assertIsArray($content['parameters'] ?? null); + self::assertSame('read', $content['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $content); + self::assertArrayNotHasKey('classificationReason', $content); } public function testFullModeReturnsKnownMutatingMethodByModuleAndActionSelectors(): void @@ -76,6 +79,9 @@ public function testFullModeReturnsKnownMutatingMethodByModuleAndActionSelectors self::assertSame('addUser', $content['action'] ?? null); self::assertSame('UsersManager.addUser', $content['method'] ?? null); self::assertIsArray($content['parameters'] ?? null); + self::assertSame('create', $content['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $content); + self::assertArrayNotHasKey('classificationReason', $content); } public function testReadModeRejectsWriteOnlyMethod(): void diff --git a/tests/Integration/McpTools/ApiListTest.php b/tests/Integration/McpTools/ApiListTest.php index df3f60e..86b0476 100644 --- a/tests/Integration/McpTools/ApiListTest.php +++ b/tests/Integration/McpTools/ApiListTest.php @@ -60,6 +60,9 @@ public function testReadModeExposesReadOnlyActionsOnly(): void self::assertIsArray($method); self::assertArrayHasKey('action', $method); self::assertIsString($method['action']); + self::assertSame('read', $method['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $method); + self::assertArrayNotHasKey('classificationReason', $method); $normalizedAction = strtolower($method['action']); self::assertTrue( str_starts_with($normalizedAction, 'get') || str_starts_with($normalizedAction, 'is'), @@ -243,6 +246,73 @@ public function testReturnsPagedResultsWithCursor(): void self::assertSame([], array_values(array_intersect($firstPageMethods, $secondPageMethods))); } + public function testCategoryFilterReturnsOnlyClassifiedCrudMatches(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['category' => 'create', 'limit' => 100], + __METHOD__, + ); + + $methods = $content['methods'] ?? null; + self::assertIsArray($methods); + self::assertNotEmpty($methods); + + foreach ($methods as $method) { + self::assertSame('create', $method['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $method); + self::assertArrayNotHasKey('classificationReason', $method); + } + } + + public function testCategoryFilterCanExcludeLowConfidenceMethods(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['category' => 'update', 'search' => 'sendreport', 'limit' => 10], + __METHOD__, + ); + + self::assertSame([], $content['methods'] ?? null); + } + + public function testUncategorizedCategoryFilterReturnsOnlyUnclassifiedMethods(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['category' => 'uncategorized', 'search' => 'sendreport', 'limit' => 10], + __METHOD__, + ); + + $methods = $content['methods'] ?? null; + self::assertIsArray($methods); + self::assertNotEmpty($methods); + + foreach ($methods as $method) { + self::assertNull($method['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $method); + self::assertArrayNotHasKey('classificationReason', $method); + } + } + public function testRejectsInvalidLimit(): void { McpTestHelper::setRawApiAccessMode('read'); @@ -279,6 +349,42 @@ public function testRejectsInvalidSort(): void self::assertStringContainsString('sort', $message->message ?? ''); } + public function testRejectsInvalidCategory(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $message = McpTestHelper::callToolExpectInvalidParams( + $server, + $sessionId, + 'matomo_api_list', + ['category' => 'unsupported'], + __METHOD__, + ); + + self::assertStringContainsString("Invalid parameters for tool 'matomo_api_list':", $message->message ?? ''); + self::assertStringContainsString('category', $message->message ?? ''); + } + + public function testRejectsReportsCategory(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $message = McpTestHelper::callToolExpectInvalidParams( + $server, + $sessionId, + 'matomo_api_list', + ['category' => 'reports'], + __METHOD__, + ); + + self::assertStringContainsString("Invalid parameters for tool 'matomo_api_list':", $message->message ?? ''); + self::assertStringContainsString('category', $message->message ?? ''); + } + public function testRejectsInvalidCursor(): void { McpTestHelper::setRawApiAccessMode('read'); @@ -349,6 +455,33 @@ public function testRejectsCursorFromDifferentFilterContext(): void ); } + public function testRejectsCursorFromDifferentCategoryContext(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + + $firstPage = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['limit' => 1, 'sort' => ApiMethodsPagination::SORT_METHOD_ASC, 'category' => 'read'], + __METHOD__ . '#1', + ); + $nextCursor = $firstPage['next_cursor'] ?? null; + self::assertIsString($nextCursor); + + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + 'matomo_api_list', + ['cursor' => $nextCursor, 'sort' => ApiMethodsPagination::SORT_METHOD_ASC, 'category' => 'create'], + 'Invalid cursor.', + __METHOD__ . '#2', + ); + } + public function testNoneModeHidesAndRejectsToolCall(): void { McpTestHelper::setRawApiAccessMode('none'); diff --git a/tests/Unit/McpTools/ApiCallTest.php b/tests/Unit/McpTools/ApiCallTest.php index 8d9be87..f727e86 100644 --- a/tests/Unit/McpTools/ApiCallTest.php +++ b/tests/Unit/McpTools/ApiCallTest.php @@ -16,6 +16,7 @@ use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiCallRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; use Piwik\Plugins\McpServer\McpTools\ApiCall; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; use Piwik\Plugins\McpServer\SystemSettings; use stdClass; @@ -53,7 +54,15 @@ public function callApi( return new ApiCallRecord( '6.0.0', - new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), + new ApiMethodSummaryRecord( + 'API', + 'getMatomoVersion', + 'API.getMatomoVersion', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ), ); } }, @@ -69,6 +78,7 @@ public function callApi( 'action' => 'getMatomoVersion', 'method' => 'API.getMatomoVersion', 'parameters' => [], + 'operationCategory' => 'read', ], ], $actual); /** @var array $capturedValues */ @@ -108,7 +118,15 @@ public function callApi( return new ApiCallRecord( ['success' => true], - new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), + new ApiMethodSummaryRecord( + 'UsersManager', + 'addUser', + 'UsersManager.addUser', + [], + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:add', + ), ); } }, diff --git a/tests/Unit/McpTools/ApiGetTest.php b/tests/Unit/McpTools/ApiGetTest.php index b63ba7f..445a016 100644 --- a/tests/Unit/McpTools/ApiGetTest.php +++ b/tests/Unit/McpTools/ApiGetTest.php @@ -16,6 +16,7 @@ use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryQueryRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; use Piwik\Plugins\McpServer\McpTools\ApiGet; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; use Piwik\Plugins\McpServer\SystemSettings; use stdClass; @@ -59,6 +60,9 @@ public function getApiMethodSummaryBySelector( action: 'getMatomoVersion', method: 'API.getMatomoVersion', parameters: [], + operationCategory: ApiMethodOperationClassifier::CATEGORY_READ, + classificationConfidence: ApiMethodOperationClassifier::CONFIDENCE_HIGH, + classificationReason: 'action-prefix:get', ); } }, @@ -72,6 +76,7 @@ public function getApiMethodSummaryBySelector( 'action' => 'getMatomoVersion', 'method' => 'API.getMatomoVersion', 'parameters' => [], + 'operationCategory' => 'read', ], $actual); /** @var array $capturedValues */ $capturedValues = $captured->values; @@ -115,6 +120,9 @@ public function getApiMethodSummaryBySelector( action: 'addUser', method: 'UsersManager.addUser', parameters: [], + operationCategory: ApiMethodOperationClassifier::CATEGORY_CREATE, + classificationConfidence: ApiMethodOperationClassifier::CONFIDENCE_HIGH, + classificationReason: 'action-prefix:add', ); } }, @@ -128,6 +136,7 @@ public function getApiMethodSummaryBySelector( 'action' => 'addUser', 'method' => 'UsersManager.addUser', 'parameters' => [], + 'operationCategory' => 'create', ], $actual); /** @var array $capturedValues */ $capturedValues = $captured->values; diff --git a/tests/Unit/McpTools/ApiListTest.php b/tests/Unit/McpTools/ApiListTest.php index 240f2a2..cf49ddc 100644 --- a/tests/Unit/McpTools/ApiListTest.php +++ b/tests/Unit/McpTools/ApiListTest.php @@ -17,6 +17,7 @@ use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryQueryRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; use Piwik\Plugins\McpServer\McpTools\ApiList; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; use Piwik\Plugins\McpServer\Support\Pagination\ApiMethodsPagination; use Piwik\Plugins\McpServer\Support\Pagination\CursorPaginator; use Piwik\Plugins\McpServer\Support\Tooling\PaginatedCollectionResponder; @@ -33,7 +34,15 @@ public function testListReturnsReadOnlyMethodsInReadMode(): void $tool = new ApiList( $this->createQueryServiceStub( static fn(ApiMethodSummaryQueryRecord $query): array => [ - new ApiMethodSummaryRecord('UsersManager', 'getUsers', 'UsersManager.getUsers', []), + new ApiMethodSummaryRecord( + 'UsersManager', + 'getUsers', + 'UsersManager.getUsers', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ), ] ), new PaginatedCollectionResponder(new CursorPaginator()), @@ -49,6 +58,7 @@ public function testListReturnsReadOnlyMethodsInReadMode(): void 'action' => 'getUsers', 'method' => 'UsersManager.getUsers', 'parameters' => [], + 'operationCategory' => 'read', ], ], 'next_cursor' => null, @@ -64,18 +74,26 @@ public function testListReturnsAllMethodsInFullModeAndSupportsFilters(): void $tool = new ApiList( $this->createQueryServiceStub( static function (ApiMethodSummaryQueryRecord $query) use (&$capturedQuery): array { - $capturedQuery = $query; - - return [ - new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), - ]; + $capturedQuery = $query; + + return [ + new ApiMethodSummaryRecord( + 'UsersManager', + 'addUser', + 'UsersManager.addUser', + [], + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:add', + ), + ]; }, ), new PaginatedCollectionResponder(new CursorPaginator()), $this->createSystemSettingsStub('full'), ); - $actual = $tool->list(module: 'usersmanager', search: 'add', limit: 10); + $actual = $tool->list(module: 'usersmanager', search: 'add', category: 'create', limit: 10); self::assertSame([ 'methods' => [ @@ -84,6 +102,7 @@ static function (ApiMethodSummaryQueryRecord $query) use (&$capturedQuery): arra 'action' => 'addUser', 'method' => 'UsersManager.addUser', 'parameters' => [], + 'operationCategory' => 'create', ], ], 'next_cursor' => null, @@ -94,6 +113,53 @@ static function (ApiMethodSummaryQueryRecord $query) use (&$capturedQuery): arra self::assertSame('full', $capturedQuery->accessMode); self::assertSame('usersmanager', $capturedQuery->module); self::assertSame('add', $capturedQuery->search); + self::assertSame('create', $capturedQuery->operationCategory); + } + + public function testListSupportsUncategorizedCategoryFilter(): void + { + $capturedQuery = null; + + $tool = new ApiList( + $this->createQueryServiceStub( + static function (ApiMethodSummaryQueryRecord $query) use (&$capturedQuery): array { + $capturedQuery = $query; + + return [ + new ApiMethodSummaryRecord( + 'ScheduledReports', + 'sendReport', + 'ScheduledReports.sendReport', + [], + null, + ApiMethodOperationClassifier::CONFIDENCE_LOW, + 'unsupported-action-prefix:send', + ), + ]; + }, + ), + new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), + ); + + $actual = $tool->list(category: 'uncategorized', limit: 10); + + self::assertSame([ + 'methods' => [ + [ + 'module' => 'ScheduledReports', + 'action' => 'sendReport', + 'method' => 'ScheduledReports.sendReport', + 'parameters' => [], + 'operationCategory' => null, + ], + ], + 'next_cursor' => null, + 'has_more' => false, + 'total_rows' => 1, + ], $actual); + self::assertInstanceOf(ApiMethodSummaryQueryRecord::class, $capturedQuery); + self::assertSame('uncategorized', $capturedQuery->operationCategory); } public function testListRejectsInvalidCursor(): void @@ -122,7 +188,7 @@ public function testListSupportsPaginationAndSortOrdering(): void self::assertCount(2, $firstPage['methods']); self::assertTrue($firstPage['has_more']); self::assertIsString($firstPage['next_cursor']); - self::assertSame(5, $firstPage['total_rows']); + self::assertSame(6, $firstPage['total_rows']); $secondPage = $tool->list( limit: 2, @@ -132,7 +198,7 @@ public function testListSupportsPaginationAndSortOrdering(): void self::assertCount(2, $secondPage['methods']); self::assertTrue($secondPage['has_more']); self::assertIsString($secondPage['next_cursor']); - self::assertSame(5, $secondPage['total_rows']); + self::assertSame(6, $secondPage['total_rows']); $firstPageMethods = array_map( static fn(array $row): string => $row['method'], @@ -190,6 +256,23 @@ public function testListRejectsCursorWhenSearchChanges(): void $tool->list(limit: 1, cursor: $cursor, sort: ApiMethodsPagination::SORT_METHOD_ASC, search: 'add'); } + public function testListRejectsCursorWhenCategoryChanges(): void + { + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), + ); + + $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC, category: 'read'); + $cursor = $firstPage['next_cursor'] ?? null; + self::assertIsString($cursor); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Invalid cursor.'); + $tool->list(limit: 1, cursor: $cursor, sort: ApiMethodsPagination::SORT_METHOD_ASC, category: 'create'); + } + public function testListRejectsCursorWhenModuleChanges(): void { $tool = new ApiList( @@ -291,11 +374,60 @@ private function createExpandedQueryServiceStub(): ApiMethodSummaryQueryServiceI public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array { $records = [ - new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), - new ApiMethodSummaryRecord('SitesManager', 'deleteSite', 'SitesManager.deleteSite', []), - new ApiMethodSummaryRecord('SitesManager', 'isSiteNameUnique', 'SitesManager.isSiteNameUnique', []), - new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), - new ApiMethodSummaryRecord('UsersManager', 'getUsers', 'UsersManager.getUsers', []), + new ApiMethodSummaryRecord( + 'API', + 'getMatomoVersion', + 'API.getMatomoVersion', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ), + new ApiMethodSummaryRecord( + 'SitesManager', + 'deleteSite', + 'SitesManager.deleteSite', + [], + ApiMethodOperationClassifier::CATEGORY_DELETE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:delete', + ), + new ApiMethodSummaryRecord( + 'SitesManager', + 'isSiteNameUnique', + 'SitesManager.isSiteNameUnique', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:is', + ), + new ApiMethodSummaryRecord( + 'UsersManager', + 'addUser', + 'UsersManager.addUser', + [], + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:add', + ), + new ApiMethodSummaryRecord( + 'UsersManager', + 'getUsers', + 'UsersManager.getUsers', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ), + new ApiMethodSummaryRecord( + 'ScheduledReports', + 'sendReport', + 'ScheduledReports.sendReport', + [], + null, + ApiMethodOperationClassifier::CONFIDENCE_LOW, + 'unsupported-action-prefix:send', + ), ]; return array_values(array_filter( @@ -314,12 +446,29 @@ static function (ApiMethodSummaryRecord $record) use ($query): bool { } if ($query->search === '') { - return true; + if ($query->operationCategory === '') { + return true; + } + + return $record->operationCategory === $query->operationCategory; } - return str_contains(strtolower($record->method), $query->search) + $matchesSearch = str_contains(strtolower($record->method), $query->search) || str_contains(strtolower($record->module), $query->search) || str_contains(strtolower($record->action), $query->search); + if (!$matchesSearch) { + return false; + } + + if ($query->operationCategory === '') { + return true; + } + + if ($query->operationCategory === ApiMethodOperationClassifier::CATEGORY_UNCATEGORIZED) { + return $record->operationCategory === null; + } + + return $record->operationCategory === $query->operationCategory; }, )); } diff --git a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php index d8bc9e1..12887a9 100644 --- a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php +++ b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php @@ -16,6 +16,7 @@ use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryQueryRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; use Piwik\Plugins\McpServer\Services\Api\ApiMethodSummaryQueryService; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; /** * @group McpServer @@ -261,11 +262,52 @@ public function testFilterRecordsAppliesCaseInsensitiveSearchAcrossMethodActionA ApiMethodSummaryQueryRecord::fromInputs('full', null, 'sitesmanager'), ); self::assertSame( - ['SitesManager.deleteSite', 'SitesManager.isSiteNameUnique'], + ['SitesManager.deleteSite', 'SitesManager.isSiteNameUnique', 'SitesManager.setDefaultTimezone'], array_values(array_map(static fn(ApiMethodSummaryRecord $record): string => $record->method, $byModule)), ); } + public function testFilterRecordsAppliesOperationCategoryFilter(): void + { + $service = new ApiMethodSummaryQueryService(); + + $records = $service->filterRecords( + $this->createMethodRecords(), + ApiMethodSummaryQueryRecord::fromInputs('full', null, null, 'update'), + ); + + self::assertSame( + ['SitesManager.setDefaultTimezone'], + array_values(array_map(static fn(ApiMethodSummaryRecord $record): string => $record->method, $records)), + ); + } + + public function testFilterRecordsAppliesUncategorizedOperationCategoryFilter(): void + { + $service = new ApiMethodSummaryQueryService(); + + $records = $service->filterRecords( + [ + ...$this->createMethodRecords(), + new ApiMethodSummaryRecord( + 'ScheduledReports', + 'sendReport', + 'ScheduledReports.sendReport', + [], + null, + ApiMethodOperationClassifier::CONFIDENCE_LOW, + 'unsupported-action-prefix:send', + ), + ], + ApiMethodSummaryQueryRecord::fromInputs('full', null, null, 'uncategorized'), + ); + + self::assertSame( + ['ScheduledReports.sendReport'], + array_values(array_map(static fn(ApiMethodSummaryRecord $record): string => $record->method, $records)), + ); + } + public function testFindApiMethodSummaryRecordMatchesMethodCaseInsensitively(): void { $service = new ApiMethodSummaryQueryService(); @@ -312,11 +354,60 @@ public function testFindApiMethodSummaryRecordReturnsNullWhenNoMatchExists(): vo private function createMethodRecords(): array { return [ - new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), - new ApiMethodSummaryRecord('SitesManager', 'deleteSite', 'SitesManager.deleteSite', []), - new ApiMethodSummaryRecord('SitesManager', 'isSiteNameUnique', 'SitesManager.isSiteNameUnique', []), - new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), - new ApiMethodSummaryRecord('UsersManager', 'getUsers', 'UsersManager.getUsers', []), + new ApiMethodSummaryRecord( + 'API', + 'getMatomoVersion', + 'API.getMatomoVersion', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ), + new ApiMethodSummaryRecord( + 'SitesManager', + 'deleteSite', + 'SitesManager.deleteSite', + [], + ApiMethodOperationClassifier::CATEGORY_DELETE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:delete', + ), + new ApiMethodSummaryRecord( + 'SitesManager', + 'isSiteNameUnique', + 'SitesManager.isSiteNameUnique', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:is', + ), + new ApiMethodSummaryRecord( + 'UsersManager', + 'addUser', + 'UsersManager.addUser', + [], + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:add', + ), + new ApiMethodSummaryRecord( + 'UsersManager', + 'getUsers', + 'UsersManager.getUsers', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ), + new ApiMethodSummaryRecord( + 'SitesManager', + 'setDefaultTimezone', + 'SitesManager.setDefaultTimezone', + [], + ApiMethodOperationClassifier::CATEGORY_UPDATE, + ApiMethodOperationClassifier::CONFIDENCE_MEDIUM, + 'action-prefix:set', + ), ]; } } diff --git a/tests/Unit/Services/Segments/SegmentDetailQueryServiceTest.php b/tests/Unit/Services/Segments/SegmentDetailQueryServiceTest.php index 13b9b0b..25b28c7 100644 --- a/tests/Unit/Services/Segments/SegmentDetailQueryServiceTest.php +++ b/tests/Unit/Services/Segments/SegmentDetailQueryServiceTest.php @@ -13,9 +13,11 @@ use Matomo\Dependencies\McpServer\Mcp\Exception\ToolCallException; use PHPUnit\Framework\TestCase; +use Piwik\NoAccessException; use Piwik\Plugins\McpServer\Contracts\Ports\Segments\CoreSegmentEditorGatewayInterface; use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; use Piwik\Plugins\McpServer\Services\Segments\SegmentDetailQueryService; +use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; /** * @group McpServer @@ -111,6 +113,41 @@ public function testNormalizeSegmentDetailRowsThrowsWhenRowIsNotArray(): void ); } + /** + * @dataProvider provideInstanceBasedAccessExceptions + */ + public function testGetSegmentDetailsForSiteMapsInstanceBasedAccessFailureToNotFound(\Throwable $exception): void + { + $gateway = $this->createMock(CoreSegmentEditorGatewayInterface::class); + $gateway->expects(self::once()) + ->method('getAll') + ->with(9) + ->willThrowException($exception); + + $capabilityGateway = $this->createMock(PluginCapabilityGatewayInterface::class); + $capabilityGateway->expects(self::once()) + ->method('isPluginActivated') + ->with('SegmentEditor') + ->willReturn(true); + + $service = new SegmentDetailQueryService($gateway, $capabilityGateway); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Segment not found.'); + $service->getSegmentDetailsForSite(9); + } + + /** + * @return array + */ + public static function provideInstanceBasedAccessExceptions(): array + { + return [ + 'NoAccessException with empty message' => [new NoAccessException('')], + 'AccessDeniedLikeException with empty message' => [new AccessDeniedLikeException('')], + ]; + } + public function testGetSegmentDetailsForSiteMapsMessageBasedAccessFailureToNotFound(): void { $gateway = $this->createMock(CoreSegmentEditorGatewayInterface::class); diff --git a/tests/Unit/Support/Api/ApiMethodOperationClassifierTest.php b/tests/Unit/Support/Api/ApiMethodOperationClassifierTest.php new file mode 100644 index 0000000..bea6272 --- /dev/null +++ b/tests/Unit/Support/Api/ApiMethodOperationClassifierTest.php @@ -0,0 +1,154 @@ + Date: Wed, 1 Apr 2026 21:19:30 +0200 Subject: [PATCH 08/15] Wrap raw API access around CRUD-style classification --- Services/Api/ApiMethodSummaryQueryService.php | 2 + Support/Access/RawApiAccessMode.php | 33 ++++++- Support/Access/RawApiMethodPolicy.php | 33 +++++-- SystemSettings.php | 3 + docs/faq.md | 14 +-- lang/en.json | 13 +-- tests/Integration/McpServerTest.php | 9 ++ tests/Integration/McpTools/ApiCallTest.php | 58 ++++++++++++- tests/Integration/McpTools/ApiGetTest.php | 53 +++++++++++- tests/Integration/McpTools/ApiListTest.php | 80 ++++++++++++++--- tests/Integration/McpToolsContractTest.php | 39 +++++++++ tests/Integration/SystemSettingsTest.php | 9 ++ tests/Unit/McpServerFactoryTest.php | 30 ++++--- tests/Unit/McpTools/ApiListTest.php | 9 +- .../Api/ApiMethodSummaryQueryServiceTest.php | 47 +++++++++- .../Support/Access/RawApiAccessModeTest.php | 37 ++++++++ .../Support/Access/RawApiMethodPolicyTest.php | 85 +++++++++++++++++-- 17 files changed, 489 insertions(+), 65 deletions(-) diff --git a/Services/Api/ApiMethodSummaryQueryService.php b/Services/Api/ApiMethodSummaryQueryService.php index f910892..06f7fc8 100644 --- a/Services/Api/ApiMethodSummaryQueryService.php +++ b/Services/Api/ApiMethodSummaryQueryService.php @@ -276,6 +276,8 @@ private function filterByAccessPolicy(array $records, string $accessMode): array $accessMode, $record->method, $record->action, + $record->operationCategory, + $record->classificationConfidence, ) )); } diff --git a/Support/Access/RawApiAccessMode.php b/Support/Access/RawApiAccessMode.php index 0dd10f6..2340492 100644 --- a/Support/Access/RawApiAccessMode.php +++ b/Support/Access/RawApiAccessMode.php @@ -11,10 +11,15 @@ namespace Piwik\Plugins\McpServer\Support\Access; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; + final class RawApiAccessMode { public const NONE = 'none'; public const READ = 'read'; + public const CREATE = 'create'; + public const UPDATE = 'update'; + public const DELETE = 'delete'; public const FULL = 'full'; public const DEFAULT = self::NONE; @@ -29,6 +34,9 @@ public static function normalize(mixed $configuredMode): string if ( $mode !== self::NONE && $mode !== self::READ + && $mode !== self::CREATE + && $mode !== self::UPDATE + && $mode !== self::DELETE && $mode !== self::FULL ) { return self::DEFAULT; @@ -39,6 +47,29 @@ public static function normalize(mixed $configuredMode): string public static function allowsToolRegistration(string $mode): bool { - return $mode === self::READ || $mode === self::FULL; + return $mode !== self::NONE; + } + + public static function allowsCategory(string $mode, ?string $category): bool + { + if ($mode === self::FULL) { + return true; + } + + $normalizedCategory = ApiMethodOperationClassifier::normalizeCategory($category); + if ($normalizedCategory === '') { + return false; + } + + return match ($mode) { + self::READ => $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_READ, + self::CREATE => $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_READ + || $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_CREATE, + self::UPDATE => $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_READ + || $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_UPDATE, + self::DELETE => $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_READ + || $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_DELETE, + default => false, + }; } } diff --git a/Support/Access/RawApiMethodPolicy.php b/Support/Access/RawApiMethodPolicy.php index 495dc97..f213947 100644 --- a/Support/Access/RawApiMethodPolicy.php +++ b/Support/Access/RawApiMethodPolicy.php @@ -11,6 +11,8 @@ namespace Piwik\Plugins\McpServer\Support\Access; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; + final class RawApiMethodPolicy { /** @var array */ @@ -27,8 +29,13 @@ final class RawApiMethodPolicy 'treemapvisualization.gettreemapdata' => true, ]; - public static function allowsMethod(string $accessMode, string $method, string $action): bool - { + public static function allowsMethod( + string $accessMode, + string $method, + string $action, + ?string $operationCategory = null, + ?string $classificationConfidence = null, + ): bool { if (self::isDeniedMethod($method)) { return false; } @@ -37,11 +44,15 @@ public static function allowsMethod(string $accessMode, string $method, string $ return true; } - if ($accessMode !== RawApiAccessMode::READ) { + if ($accessMode === RawApiAccessMode::NONE) { return false; } - return self::isReadHeuristicAction($action); + if (self::normalizeConfidence($classificationConfidence) === ApiMethodOperationClassifier::CONFIDENCE_LOW) { + return false; + } + + return RawApiAccessMode::allowsCategory($accessMode, $operationCategory); } public static function isDeniedMethod(string $method): bool @@ -49,12 +60,18 @@ public static function isDeniedMethod(string $method): bool return isset(self::DENIED_METHODS[self::normalizeSelectorValue($method)]); } - public static function isReadHeuristicAction(string $action): bool + private static function normalizeConfidence(?string $confidence): string { - $normalizedAction = self::normalizeSelectorValue($action); + $normalizedConfidence = self::normalizeSelectorValue($confidence); + + if ( + $normalizedConfidence !== ApiMethodOperationClassifier::CONFIDENCE_HIGH + && $normalizedConfidence !== ApiMethodOperationClassifier::CONFIDENCE_MEDIUM + ) { + return ApiMethodOperationClassifier::CONFIDENCE_LOW; + } - return str_starts_with($normalizedAction, 'get') - || str_starts_with($normalizedAction, 'is'); + return $normalizedConfidence; } private static function normalizeSelectorValue(?string $value): string diff --git a/SystemSettings.php b/SystemSettings.php index 2ba6a7f..b266e89 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -65,6 +65,9 @@ function (FieldConfig $field) { $field->availableValues = [ RawApiAccessMode::NONE => Piwik::translate('McpServer_RawApiAccessModeOptionNone'), RawApiAccessMode::READ => Piwik::translate('McpServer_RawApiAccessModeOptionRead'), + RawApiAccessMode::CREATE => Piwik::translate('McpServer_RawApiAccessModeOptionCreate'), + RawApiAccessMode::UPDATE => Piwik::translate('McpServer_RawApiAccessModeOptionUpdate'), + RawApiAccessMode::DELETE => Piwik::translate('McpServer_RawApiAccessModeOptionDelete'), RawApiAccessMode::FULL => Piwik::translate('McpServer_RawApiAccessModeOptionFull'), ]; }, diff --git a/docs/faq.md b/docs/faq.md index 26640bd..2195d83 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -31,9 +31,14 @@ Configure raw Matomo API tool access in **Administration -> System -> Plugin Set - Use the **Raw Matomo API tool access** setting to control visibility for `matomo_api_list`, `matomo_api_get`, and `matomo_api_call`. - `Disabled`: hides `matomo_api_list`, `matomo_api_get`, and `matomo_api_call` (default). -- `Best-effort read filtering`: shows the raw API tools, blocks known risky proxy-like APIs, and otherwise falls back to method-name filtering (`get`/`is`) for discovered methods. -- `Full API access`: shows the raw API tools and allows direct API calls, including state-changing or destructive methods. -- Best-effort read filtering is not a strict security boundary for unknown plugin APIs. +- `Read access`: allows classified read methods. +- `Create access`: allows classified read and create methods. +- `Update access`: allows classified read and update methods. +- `Delete access`: allows classified read and delete methods. +- `Full API access`: allows direct API calls for all non-restricted methods, including state-changing or destructive methods. +- The dedicated report tools remain available independently of this setting. +- Permanently restricted methods in `RawApiMethodPolicy` remain blocked in every mode. +- Low-confidence or unclassified direct API methods require `Full API access`. - Direct API access can expose raw or personal data depending on enabled Matomo features. Review privacy and security requirements before enabling it, and consult your DPO or compliance owner when needed. ## Enabling MCP @@ -51,8 +56,7 @@ When disabled, requests to `index.php?module=API&method=McpServer.mcp&format=mcp The plugin is focused on read-oriented analytics workflows. The exact tool surface may expand over time, but the initial release includes tools around: - sites -- reports and report metadata -- processed report data +- reports, report metadata, and processed report data - goals - segments - dimensions diff --git a/lang/en.json b/lang/en.json index f896790..b378386 100644 --- a/lang/en.json +++ b/lang/en.json @@ -32,14 +32,17 @@ "EnableMcpHelpPurpose": "Enable the Matomo MCP Server (Model Context Protocol) to allow AI tools and assistants to access analytics context from your Matomo instance.", "EnableMcpHelpUrl": "Your MCP URL: %1$s%2$s%3$s", "EnableMcpTitle": "Enable MCP Server (Model Context Protocol)", - "RawApiAccessModeHelpDataScope": "Direct Matomo API access can expose the same data available through the Matomo user interface or Reporting API, including raw or personal data when features such as the Visitor Log are enabled.", - "RawApiAccessModeHelpDestructive": "Full API access can execute state-changing or destructive API methods, including actions that modify configuration or delete data.", + "RawApiAccessModeHelpDataScope": "Direct Matomo API access can expose the same data available through the Matomo user interface or direct API endpoints, including raw or personal data when features such as the Visitor Log are enabled.", + "RawApiAccessModeHelpDestructive": "Create, update, delete, and full API access can execute state-changing or destructive API methods, including actions that modify configuration or delete data.", "RawApiAccessModeHelpPolicy": "Before enabling direct API access, ensure this complies with your organization's privacy and security policies and applicable regulations. You may need approval from your data protection officer (DPO) or another compliance owner.", - "RawApiAccessModeHelpPurpose": "Control whether the raw Matomo API tools are hidden, use best-effort read filtering, or allow direct API calls.", - "RawApiAccessModeHelpReadFallback": "Best-effort read filtering is not a strict security boundary. It blocks known risky APIs and otherwise falls back to method-name filtering for discovered API methods.", + "RawApiAccessModeHelpPurpose": "Control whether the direct raw Matomo API tools are hidden or exposed for classified read, create, update, delete, or full API access.", + "RawApiAccessModeHelpReadFallback": "Direct API access uses CRUD-style classification for discovered API methods. Dedicated report tools remain available independently, permanently restricted APIs stay blocked in every mode, and low-confidence methods require full access.", + "RawApiAccessModeOptionCreate": "Create access", + "RawApiAccessModeOptionDelete": "Delete access", "RawApiAccessModeOptionFull": "Full API access", "RawApiAccessModeOptionNone": "Disabled", - "RawApiAccessModeOptionRead": "Best-effort read filtering", + "RawApiAccessModeOptionRead": "Read access", + "RawApiAccessModeOptionUpdate": "Update access", "RawApiAccessModeTitle": "Raw Matomo API tool access" } } diff --git a/tests/Integration/McpServerTest.php b/tests/Integration/McpServerTest.php index c59ef8e..6e948f3 100644 --- a/tests/Integration/McpServerTest.php +++ b/tests/Integration/McpServerTest.php @@ -144,6 +144,15 @@ public function testContainerSystemSettingCanBeToggled(): void $systemSettings->rawApiAccessMode->setValue('read'); self::assertSame('read', $systemSettings->getRawApiAccessMode()); + $systemSettings->rawApiAccessMode->setValue('create'); + self::assertSame('create', $systemSettings->getRawApiAccessMode()); + + $systemSettings->rawApiAccessMode->setValue('update'); + self::assertSame('update', $systemSettings->getRawApiAccessMode()); + + $systemSettings->rawApiAccessMode->setValue('delete'); + self::assertSame('delete', $systemSettings->getRawApiAccessMode()); + $systemSettings->rawApiAccessMode->setValue('full'); self::assertSame('full', $systemSettings->getRawApiAccessMode()); } finally { diff --git a/tests/Integration/McpTools/ApiCallTest.php b/tests/Integration/McpTools/ApiCallTest.php index a9c6ca5..546fae0 100644 --- a/tests/Integration/McpTools/ApiCallTest.php +++ b/tests/Integration/McpTools/ApiCallTest.php @@ -167,23 +167,29 @@ public function testReadModeRejectsWriteOnlyMethod(): void ); } - public function testReadModeRejectsNonHeuristicMethod(): void + public function testReadModeCallsMediumConfidenceReadMethod(): void { McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); - McpTestHelper::callToolAndAssertError( + $content = McpTestHelper::callToolAndAssertSuccess( $server, $sessionId, ApiCall::TOOL_NAME, ['method' => 'UsersManager.hasSuperUserAccess'], - 'API method not found or unavailable.', __METHOD__, ); + + $resolvedMethod = $content['resolvedMethod'] ?? null; + self::assertIsArray($resolvedMethod); + self::assertSame('UsersManager.hasSuperUserAccess', $resolvedMethod['method'] ?? null); + self::assertSame('read', $resolvedMethod['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $resolvedMethod); + self::assertArrayNotHasKey('classificationReason', $resolvedMethod); } - public function testFullModeCallsKnownNonHeuristicMethod(): void + public function testFullModeCallsKnownMediumConfidenceReadMethod(): void { McpTestHelper::setRawApiAccessMode('full'); @@ -295,6 +301,50 @@ public function testFullModeAttemptsMutatingMethodCall(): void ); } + public function testCreateModeAttemptsCreateMethodCall(): void + { + McpTestHelper::setRawApiAccessMode('create'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $result = McpTestHelper::callTool( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['method' => 'UsersManager.addUser'], + __METHOD__, + ); + + McpTestHelper::assertToolError($result); + $content = $result->content[0] ?? null; + self::assertInstanceOf(\Matomo\Dependencies\McpServer\Mcp\Schema\Content\TextContent::class, $content); + } + + public function testDeleteModeAttemptsDeleteMethodCall(): void + { + McpTestHelper::setRawApiAccessMode('delete'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $result = McpTestHelper::callTool( + $server, + $sessionId, + ApiCall::TOOL_NAME, + ['method' => 'SitesManager.deleteSite'], + __METHOD__, + ); + + McpTestHelper::assertToolError($result); + $content = $result->content[0] ?? null; + self::assertInstanceOf(\Matomo\Dependencies\McpServer\Mcp\Schema\Content\TextContent::class, $content); + $errorText = $content->text; + self::assertIsString($errorText); + self::assertTrue( + $errorText === 'Matomo API request failed.' + || str_starts_with($errorText, 'Matomo API request failed: '), + ); + } + public function testRejectsReservedParameterKeys(): void { McpTestHelper::setRawApiAccessMode('read'); diff --git a/tests/Integration/McpTools/ApiGetTest.php b/tests/Integration/McpTools/ApiGetTest.php index c881ff3..f128235 100644 --- a/tests/Integration/McpTools/ApiGetTest.php +++ b/tests/Integration/McpTools/ApiGetTest.php @@ -100,23 +100,27 @@ public function testReadModeRejectsWriteOnlyMethod(): void ); } - public function testReadModeRejectsNonHeuristicMethod(): void + public function testReadModeAllowsMediumConfidenceReadMethod(): void { McpTestHelper::setRawApiAccessMode('read'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); - McpTestHelper::callToolAndAssertError( + $content = McpTestHelper::callToolAndAssertSuccess( $server, $sessionId, ApiGet::TOOL_NAME, ['method' => 'UsersManager.hasSuperUserAccess'], - 'API method not found or unavailable.', __METHOD__, ); + + self::assertSame('UsersManager.hasSuperUserAccess', $content['method'] ?? null); + self::assertSame('read', $content['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $content); + self::assertArrayNotHasKey('classificationReason', $content); } - public function testFullModeReturnsKnownNonHeuristicMethod(): void + public function testFullModeReturnsKnownMediumConfidenceReadMethod(): void { McpTestHelper::setRawApiAccessMode('full'); @@ -131,6 +135,9 @@ public function testFullModeReturnsKnownNonHeuristicMethod(): void ); self::assertSame('UsersManager.hasSuperUserAccess', $content['method'] ?? null); + self::assertSame('read', $content['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $content); + self::assertArrayNotHasKey('classificationReason', $content); } public function testReadModeRejectsBlockedProxyLikeMethod(): void @@ -214,6 +221,44 @@ public function testReadModeKeepsGetSuggestedValuesForSegmentAvailable(): void self::assertSame('API.getSuggestedValuesForSegment', $content['method'] ?? null); } + public function testCreateModeReturnsKnownCreateMethod(): void + { + McpTestHelper::setRawApiAccessMode('create'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['method' => 'UsersManager.addUser'], + __METHOD__, + ); + + self::assertSame('UsersManager.addUser', $content['method'] ?? null); + self::assertSame('create', $content['operationCategory'] ?? null); + } + + public function testDeleteModeReturnsKnownDeleteMethod(): void + { + McpTestHelper::setRawApiAccessMode('delete'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiGet::TOOL_NAME, + ['method' => 'SitesManager.deleteSite'], + __METHOD__, + ); + + self::assertSame('SitesManager.deleteSite', $content['method'] ?? null); + self::assertSame('delete', $content['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $content); + self::assertArrayNotHasKey('classificationReason', $content); + } + public function testRejectsIncompleteSplitSelectorAtSchemaLevel(): void { McpTestHelper::setRawApiAccessMode('full'); diff --git a/tests/Integration/McpTools/ApiListTest.php b/tests/Integration/McpTools/ApiListTest.php index 86b0476..8884e20 100644 --- a/tests/Integration/McpTools/ApiListTest.php +++ b/tests/Integration/McpTools/ApiListTest.php @@ -38,7 +38,7 @@ public function tearDown(): void parent::tearDown(); } - public function testReadModeExposesReadOnlyActionsOnly(): void + public function testReadModeExposesOnlyReadClassifiedMethods(): void { McpTestHelper::setRawApiAccessMode('read'); @@ -56,19 +56,29 @@ public function testReadModeExposesReadOnlyActionsOnly(): void self::assertIsArray($content['methods']); self::assertNotEmpty($content['methods']); + $methods = []; + $foundNonPrefixReadMethod = false; + foreach ($content['methods'] as $method) { self::assertIsArray($method); + self::assertArrayHasKey('method', $method); + self::assertIsString($method['method']); self::assertArrayHasKey('action', $method); self::assertIsString($method['action']); self::assertSame('read', $method['operationCategory'] ?? null); self::assertArrayNotHasKey('classificationConfidence', $method); self::assertArrayNotHasKey('classificationReason', $method); + + $methods[] = $method['method']; + $normalizedAction = strtolower($method['action']); - self::assertTrue( - str_starts_with($normalizedAction, 'get') || str_starts_with($normalizedAction, 'is'), - 'Read mode returned non-read action: ' . $method['action'], - ); + if (!str_starts_with($normalizedAction, 'get') && !str_starts_with($normalizedAction, 'is')) { + $foundNonPrefixReadMethod = true; + } } + + self::assertContains('UsersManager.hasSuperUserAccess', $methods); + self::assertTrue($foundNonPrefixReadMethod, 'Expected at least one read-classified non-get/is action.'); } public function testFullModeCanReturnMutatingActions(): void @@ -124,14 +134,14 @@ public function testReadModeHidesBlockedProxyLikeMethods(): void self::assertNotContains('TreemapVisualization.getTreemapData', $methods); } - public function testReadModeUsesHeuristicFallbackForUnknownMethods(): void + public function testReadModeAllowsMediumConfidenceReadMethods(): void { McpTestHelper::setRawApiAccessMode('read'); $methods = $this->listMethodsForCurrentConfig(500); self::assertContains('UsersManager.getUsers', $methods); - self::assertNotContains('UsersManager.hasSuperUserAccess', $methods); + self::assertContains('UsersManager.hasSuperUserAccess', $methods); } public function testFullModeHidesInternalAnnotatedMethods(): void @@ -154,9 +164,9 @@ public function testFullModeKeepsHideExceptForSuperUserMethodsVisibleForSuperUse self::assertContains('CoreAdminHome.runScheduledTasks', $methods); } - public function testFullModeAllowsNonHeuristicMethodsWhenTheyAreNotDenied(): void + public function testUpdateModeCanReturnUpdateActions(): void { - McpTestHelper::setRawApiAccessMode('full'); + McpTestHelper::setRawApiAccessMode('update'); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -165,7 +175,7 @@ public function testFullModeAllowsNonHeuristicMethodsWhenTheyAreNotDenied(): voi $server, $sessionId, 'matomo_api_list', - ['search' => 'hassuperuseraccess', 'limit' => 50], + ['search' => 'setdefaulttimezone', 'limit' => 50], __METHOD__, ); $methodsData = $content['methods'] ?? null; @@ -176,7 +186,55 @@ public function testFullModeAllowsNonHeuristicMethodsWhenTheyAreNotDenied(): voi $methodsData, ); - self::assertContains('UsersManager.hasSuperUserAccess', $methods); + self::assertContains('SitesManager.setDefaultTimezone', $methods); + } + + public function testCreateModeCanReturnCreateActions(): void + { + McpTestHelper::setRawApiAccessMode('create'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['search' => 'adduser', 'limit' => 20], + __METHOD__, + ); + + $methods = $content['methods'] ?? null; + self::assertIsArray($methods); + self::assertNotEmpty($methods); + self::assertContains('UsersManager.addUser', array_values(array_map( + static fn(array $row): string => (string) ($row['method'] ?? ''), + $methods, + ))); + } + + public function testDeleteModeCanReturnDeleteActions(): void + { + McpTestHelper::setRawApiAccessMode('delete'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['search' => 'deletesite', 'limit' => 20], + __METHOD__, + ); + + $methods = $content['methods'] ?? null; + self::assertIsArray($methods); + self::assertNotEmpty($methods); + self::assertContains('SitesManager.deleteSite', array_values(array_map( + static fn(array $row): string => (string) ($row['method'] ?? ''), + $methods, + ))); } public function testFullModeHidesBlockedProxyLikeMethods(): void diff --git a/tests/Integration/McpToolsContractTest.php b/tests/Integration/McpToolsContractTest.php index 0715741..e4442e9 100644 --- a/tests/Integration/McpToolsContractTest.php +++ b/tests/Integration/McpToolsContractTest.php @@ -199,6 +199,45 @@ public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessM self::assertFalse($callTool->annotations->openWorldHint); } + public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessModeIsCreate(): void + { + McpTestHelper::setRawApiAccessMode('create'); + $toolsByName = $this->listToolsByNameForCurrentConfig(); + + self::assertArrayHasKey('matomo_api_get', $toolsByName); + self::assertArrayHasKey('matomo_api_list', $toolsByName); + self::assertArrayHasKey(ApiCall::TOOL_NAME, $toolsByName); + + $callTool = $toolsByName[ApiCall::TOOL_NAME]; + self::assertNotNull($callTool->annotations); + self::assertFalse($callTool->annotations->readOnlyHint); + self::assertFalse($callTool->annotations->openWorldHint); + } + + public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessModeIsDelete(): void + { + McpTestHelper::setRawApiAccessMode('delete'); + $toolsByName = $this->listToolsByNameForCurrentConfig(); + + self::assertArrayHasKey('matomo_api_get', $toolsByName); + $getTool = $toolsByName['matomo_api_get']; + self::assertNotNull($getTool->annotations); + self::assertTrue($getTool->annotations->readOnlyHint); + self::assertFalse($getTool->annotations->openWorldHint); + + self::assertArrayHasKey('matomo_api_list', $toolsByName); + $tool = $toolsByName['matomo_api_list']; + self::assertNotNull($tool->annotations); + self::assertTrue($tool->annotations->readOnlyHint); + self::assertFalse($tool->annotations->openWorldHint); + + self::assertArrayHasKey(ApiCall::TOOL_NAME, $toolsByName); + $callTool = $toolsByName[ApiCall::TOOL_NAME]; + self::assertNotNull($callTool->annotations); + self::assertFalse($callTool->annotations->readOnlyHint); + self::assertFalse($callTool->annotations->openWorldHint); + } + public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessModeIsFull(): void { McpTestHelper::setRawApiAccessMode('full'); diff --git a/tests/Integration/SystemSettingsTest.php b/tests/Integration/SystemSettingsTest.php index bd938a8..b6452eb 100644 --- a/tests/Integration/SystemSettingsTest.php +++ b/tests/Integration/SystemSettingsTest.php @@ -90,6 +90,15 @@ public function testCanChangeRawApiAccessMode(): void $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::READ); self::assertSame(RawApiAccessMode::READ, $this->settings->getRawApiAccessMode()); + $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::CREATE); + self::assertSame(RawApiAccessMode::CREATE, $this->settings->getRawApiAccessMode()); + + $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::UPDATE); + self::assertSame(RawApiAccessMode::UPDATE, $this->settings->getRawApiAccessMode()); + + $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::DELETE); + self::assertSame(RawApiAccessMode::DELETE, $this->settings->getRawApiAccessMode()); + $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::FULL); self::assertSame(RawApiAccessMode::FULL, $this->settings->getRawApiAccessMode()); } finally { diff --git a/tests/Unit/McpServerFactoryTest.php b/tests/Unit/McpServerFactoryTest.php index 05365ee..3b1853f 100644 --- a/tests/Unit/McpServerFactoryTest.php +++ b/tests/Unit/McpServerFactoryTest.php @@ -411,26 +411,28 @@ public function testInvalidToolCallLogLevelFallsBackToDebug(): void self::assertSame(JsonRpcError::METHOD_NOT_FOUND, $message->code); } - public function testRawApiListToolIsHiddenWhenRawAccessModeIsNoneOrInvalid(): void - { - $toolsWhenMissing = $this->listToolNamesForCurrentConfig('none'); - self::assertNotContains('matomo_api_call', $toolsWhenMissing); - self::assertNotContains('matomo_api_get', $toolsWhenMissing); - self::assertNotContains('matomo_api_list', $toolsWhenMissing); - - $toolsWhenNone = $this->listToolNamesForCurrentConfig('invalid'); - self::assertNotContains('matomo_api_call', $toolsWhenNone); - self::assertNotContains('matomo_api_get', $toolsWhenNone); - self::assertNotContains('matomo_api_list', $toolsWhenNone); - } - - public function testRawApiListToolIsVisibleWhenRawAccessModeIsReadOrFull(): void + public function testRawApiListToolIsVisibleWhenRawAccessModeAllowsDirectApiAccess(): void { $toolsWhenRead = $this->listToolNamesForCurrentConfig('read'); self::assertContains('matomo_api_call', $toolsWhenRead); self::assertContains('matomo_api_get', $toolsWhenRead); self::assertContains('matomo_api_list', $toolsWhenRead); + $toolsWhenCreate = $this->listToolNamesForCurrentConfig('create'); + self::assertContains('matomo_api_call', $toolsWhenCreate); + self::assertContains('matomo_api_get', $toolsWhenCreate); + self::assertContains('matomo_api_list', $toolsWhenCreate); + + $toolsWhenUpdate = $this->listToolNamesForCurrentConfig('update'); + self::assertContains('matomo_api_call', $toolsWhenUpdate); + self::assertContains('matomo_api_get', $toolsWhenUpdate); + self::assertContains('matomo_api_list', $toolsWhenUpdate); + + $toolsWhenDelete = $this->listToolNamesForCurrentConfig('delete'); + self::assertContains('matomo_api_call', $toolsWhenDelete); + self::assertContains('matomo_api_get', $toolsWhenDelete); + self::assertContains('matomo_api_list', $toolsWhenDelete); + $toolsWhenFull = $this->listToolNamesForCurrentConfig('full'); self::assertContains('matomo_api_call', $toolsWhenFull); self::assertContains('matomo_api_get', $toolsWhenFull); diff --git a/tests/Unit/McpTools/ApiListTest.php b/tests/Unit/McpTools/ApiListTest.php index cf49ddc..cb58965 100644 --- a/tests/Unit/McpTools/ApiListTest.php +++ b/tests/Unit/McpTools/ApiListTest.php @@ -433,10 +433,13 @@ public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array return array_values(array_filter( $records, static function (ApiMethodSummaryRecord $record) use ($query): bool { + if ($query->accessMode === 'read' && $record->operationCategory !== 'read') { + return false; + } + if ( - $query->accessMode === 'read' - && !str_starts_with(strtolower($record->action), 'get') - && !str_starts_with(strtolower($record->action), 'is') + $query->accessMode === 'create' + && !in_array($record->operationCategory, ['read', 'create'], true) ) { return false; } diff --git a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php index 12887a9..32e3b11 100644 --- a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php +++ b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php @@ -170,22 +170,61 @@ public function testFilterRecordsAppliesReadAccessMode(): void ); } - public function testFilterRecordsUsesFullModeForNonHeuristicReadMethod(): void + public function testFilterRecordsUsesCrudModesForClassifiedMethods(): void { $service = new ApiMethodSummaryQueryService(); $readRecords = $service->filterRecords( - [new ApiMethodSummaryRecord('UsersManager', 'hasSuperUserAccess', 'UsersManager.hasSuperUserAccess', [])], + [ + new ApiMethodSummaryRecord( + 'UsersManager', + 'hasSuperUserAccess', + 'UsersManager.hasSuperUserAccess', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_MEDIUM, + 'action-prefix:has', + ), + ], ApiMethodSummaryQueryRecord::fromInputs('read'), ); + $createRecords = $service->filterRecords( + $this->createMethodRecords(), + ApiMethodSummaryQueryRecord::fromInputs('create'), + ); $fullRecords = $service->filterRecords( - [new ApiMethodSummaryRecord('UsersManager', 'hasSuperUserAccess', 'UsersManager.hasSuperUserAccess', [])], + [ + new ApiMethodSummaryRecord( + 'ScheduledReports', + 'sendReport', + 'ScheduledReports.sendReport', + [], + null, + ApiMethodOperationClassifier::CONFIDENCE_LOW, + 'unsupported-action-prefix:send', + ), + ], ApiMethodSummaryQueryRecord::fromInputs('full'), ); - self::assertSame([], $readRecords); self::assertSame( ['UsersManager.hasSuperUserAccess'], + array_values(array_map(static fn(ApiMethodSummaryRecord $record): string => $record->method, $readRecords)), + ); + self::assertSame( + [ + 'API.getMatomoVersion', + 'SitesManager.isSiteNameUnique', + 'UsersManager.addUser', + 'UsersManager.getUsers', + ], + array_values(array_map( + static fn(ApiMethodSummaryRecord $record): string => $record->method, + $createRecords, + )), + ); + self::assertSame( + ['ScheduledReports.sendReport'], array_values(array_map(static fn(ApiMethodSummaryRecord $record): string => $record->method, $fullRecords)), ); } diff --git a/tests/Unit/Support/Access/RawApiAccessModeTest.php b/tests/Unit/Support/Access/RawApiAccessModeTest.php index 531fceb..bfadb84 100644 --- a/tests/Unit/Support/Access/RawApiAccessModeTest.php +++ b/tests/Unit/Support/Access/RawApiAccessModeTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; /** * @group McpServer @@ -31,6 +32,42 @@ public function testNormalizeAcceptsSupportedValuesCaseInsensitively(): void { self::assertSame(RawApiAccessMode::NONE, RawApiAccessMode::normalize(' NONE ')); self::assertSame(RawApiAccessMode::READ, RawApiAccessMode::normalize('Read')); + self::assertSame(RawApiAccessMode::CREATE, RawApiAccessMode::normalize('CREATE')); + self::assertSame(RawApiAccessMode::UPDATE, RawApiAccessMode::normalize('update')); + self::assertSame(RawApiAccessMode::DELETE, RawApiAccessMode::normalize(' Delete ')); self::assertSame(RawApiAccessMode::FULL, RawApiAccessMode::normalize('FULL')); } + + public function testAllowsCategoryUsesCrudModeInheritance(): void + { + self::assertTrue( + RawApiAccessMode::allowsCategory(RawApiAccessMode::FULL, ApiMethodOperationClassifier::CATEGORY_DELETE), + ); + + self::assertTrue( + RawApiAccessMode::allowsCategory(RawApiAccessMode::READ, ApiMethodOperationClassifier::CATEGORY_READ), + ); + self::assertFalse( + RawApiAccessMode::allowsCategory(RawApiAccessMode::READ, ApiMethodOperationClassifier::CATEGORY_CREATE), + ); + + self::assertTrue( + RawApiAccessMode::allowsCategory(RawApiAccessMode::CREATE, ApiMethodOperationClassifier::CATEGORY_READ), + ); + self::assertTrue( + RawApiAccessMode::allowsCategory(RawApiAccessMode::CREATE, ApiMethodOperationClassifier::CATEGORY_CREATE), + ); + self::assertFalse( + RawApiAccessMode::allowsCategory(RawApiAccessMode::CREATE, ApiMethodOperationClassifier::CATEGORY_UPDATE), + ); + + self::assertTrue( + RawApiAccessMode::allowsCategory(RawApiAccessMode::UPDATE, ApiMethodOperationClassifier::CATEGORY_UPDATE), + ); + self::assertTrue( + RawApiAccessMode::allowsCategory(RawApiAccessMode::DELETE, ApiMethodOperationClassifier::CATEGORY_DELETE), + ); + self::assertFalse(RawApiAccessMode::allowsCategory(RawApiAccessMode::NONE, 'read')); + self::assertFalse(RawApiAccessMode::allowsCategory(RawApiAccessMode::READ, null)); + } } diff --git a/tests/Unit/Support/Access/RawApiMethodPolicyTest.php b/tests/Unit/Support/Access/RawApiMethodPolicyTest.php index 5788880..78f0187 100644 --- a/tests/Unit/Support/Access/RawApiMethodPolicyTest.php +++ b/tests/Unit/Support/Access/RawApiMethodPolicyTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\Support\Access\RawApiMethodPolicy; +use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; /** * @group McpServer @@ -21,27 +22,70 @@ */ class RawApiMethodPolicyTest extends TestCase { - public function testAllowsMethodUsesReadFallbackForUnknownMethods(): void + public function testAllowsMethodUsesCrudClassificationForNonFullModes(): void { self::assertTrue( - RawApiMethodPolicy::allowsMethod(RawApiAccessMode::READ, 'UsersManager.getUsers', 'getUsers'), + RawApiMethodPolicy::allowsMethod( + RawApiAccessMode::READ, + 'UsersManager.getUsers', + 'getUsers', + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + ), ); self::assertTrue( RawApiMethodPolicy::allowsMethod( RawApiAccessMode::READ, 'SitesManager.isSiteNameUnique', 'isSiteNameUnique', + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, ), ); - self::assertFalse( + self::assertTrue( RawApiMethodPolicy::allowsMethod( RawApiAccessMode::READ, 'UsersManager.hasSuperUserAccess', 'hasSuperUserAccess', + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_MEDIUM, + ), + ); + self::assertFalse( + RawApiMethodPolicy::allowsMethod( + RawApiAccessMode::READ, + 'UsersManager.addUser', + 'addUser', + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + ), + ); + self::assertTrue( + RawApiMethodPolicy::allowsMethod( + RawApiAccessMode::CREATE, + 'UsersManager.addUser', + 'addUser', + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + ), + ); + self::assertTrue( + RawApiMethodPolicy::allowsMethod( + RawApiAccessMode::UPDATE, + 'SitesManager.setDefaultTimezone', + 'setDefaultTimezone', + ApiMethodOperationClassifier::CATEGORY_UPDATE, + ApiMethodOperationClassifier::CONFIDENCE_MEDIUM, ), ); self::assertFalse( - RawApiMethodPolicy::allowsMethod(RawApiAccessMode::READ, 'UsersManager.addUser', 'addUser'), + RawApiMethodPolicy::allowsMethod( + RawApiAccessMode::UPDATE, + 'UsersManager.addUser', + 'addUser', + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + ), ); } @@ -52,17 +96,31 @@ public function testAllowsMethodLetsFullModeUseNonDeniedMethods(): void RawApiAccessMode::FULL, 'UsersManager.hasSuperUserAccess', 'hasSuperUserAccess', + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_MEDIUM, ), ); self::assertTrue( - RawApiMethodPolicy::allowsMethod(RawApiAccessMode::FULL, 'UsersManager.addUser', 'addUser'), + RawApiMethodPolicy::allowsMethod( + RawApiAccessMode::FULL, + 'UsersManager.addUser', + 'addUser', + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + ), ); } public function testAllowsMethodRejectsDeniedMethodsInReadAndFullModes(): void { self::assertFalse( - RawApiMethodPolicy::allowsMethod(RawApiAccessMode::READ, 'API.getProcessedReport', 'getProcessedReport'), + RawApiMethodPolicy::allowsMethod( + RawApiAccessMode::READ, + 'API.getProcessedReport', + 'getProcessedReport', + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + ), ); self::assertFalse( RawApiMethodPolicy::allowsMethod(RawApiAccessMode::READ, 'API.getReportMetadata', 'getReportMetadata'), @@ -75,6 +133,21 @@ public function testAllowsMethodRejectsDeniedMethodsInReadAndFullModes(): void RawApiAccessMode::FULL, 'TreemapVisualization.getTreemapData', 'getTreemapData', + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + ), + ); + } + + public function testAllowsMethodRejectsLowConfidenceMethodsOutsideFull(): void + { + self::assertFalse( + RawApiMethodPolicy::allowsMethod( + RawApiAccessMode::READ, + 'ScheduledReports.sendReport', + 'sendReport', + null, + ApiMethodOperationClassifier::CONFIDENCE_LOW, ), ); self::assertFalse( From 2928bdff994e2374e5b6aecb3aab9b397a5e8c43 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Wed, 8 Apr 2026 11:02:15 +0200 Subject: [PATCH 09/15] Split API access configuration into individual CRUD-style checks --- .../Api/ApiMethodSummaryQueryRecord.php | 3 +- Support/Access/RawApiAccessMode.php | 86 +++++++++--- SystemSettings.php | 132 +++++++++++++++--- docs/faq.md | 11 +- lang/en.json | 25 ++-- tests/Framework/McpTestHelper.php | 21 ++- tests/Integration/McpServerTest.php | 36 ++++- tests/Integration/SystemSettingsTest.php | 50 +++++-- tests/UI/McpServer_spec.js | 45 ++++-- tests/Unit/McpTools/ApiListTest.php | 10 +- .../Api/ApiMethodSummaryQueryServiceTest.php | 15 +- .../Support/Access/RawApiAccessModeTest.php | 35 ++++- .../Support/Access/RawApiMethodPolicyTest.php | 20 ++- 13 files changed, 380 insertions(+), 109 deletions(-) diff --git a/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php b/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php index 7c8fd4e..9688e67 100644 --- a/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php +++ b/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php @@ -11,6 +11,7 @@ namespace Piwik\Plugins\McpServer\Contracts\Records\Api; +use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; final class ApiMethodSummaryQueryRecord @@ -30,7 +31,7 @@ public static function fromInputs( ?string $operationCategory = null, ): self { return new self( - accessMode: trim($accessMode), + accessMode: RawApiAccessMode::normalize($accessMode), module: strtolower(trim((string) $module)), search: strtolower(trim((string) $search)), operationCategory: ApiMethodOperationClassifier::normalizeCategory($operationCategory), diff --git a/Support/Access/RawApiAccessMode.php b/Support/Access/RawApiAccessMode.php index 2340492..26b427e 100644 --- a/Support/Access/RawApiAccessMode.php +++ b/Support/Access/RawApiAccessMode.php @@ -24,34 +24,85 @@ final class RawApiAccessMode public const DEFAULT = self::NONE; + /** @var list */ + private const CRUD_MODES = [ + self::READ, + self::CREATE, + self::UPDATE, + self::DELETE, + ]; + public static function normalize(mixed $configuredMode): string { - if (!is_scalar($configuredMode)) { + if (is_array($configuredMode)) { + $tokens = $configuredMode; + } elseif (is_scalar($configuredMode)) { + $tokens = preg_split('/[\s,]+/', strtolower(trim((string) $configuredMode))) ?: []; + } else { return self::DEFAULT; } - $mode = strtolower(trim((string) $configuredMode)); - if ( - $mode !== self::NONE - && $mode !== self::READ - && $mode !== self::CREATE - && $mode !== self::UPDATE - && $mode !== self::DELETE - && $mode !== self::FULL - ) { + $normalizedModes = []; + foreach ($tokens as $token) { + if (!is_scalar($token)) { + continue; + } + + $mode = strtolower(trim((string) $token)); + if ($mode === '' || $mode === self::NONE) { + continue; + } + + if ($mode === self::FULL) { + return self::FULL; + } + + if (in_array($mode, self::CRUD_MODES, true)) { + $normalizedModes[$mode] = true; + } + } + + if ($normalizedModes === []) { return self::DEFAULT; } - return $mode; + $orderedModes = []; + foreach (self::CRUD_MODES as $mode) { + if (isset($normalizedModes[$mode])) { + $orderedModes[] = $mode; + } + } + + return implode(',', $orderedModes); + } + + public static function fromBooleans( + bool $read, + bool $create, + bool $update, + bool $delete, + bool $full, + ): string { + if ($full) { + return self::FULL; + } + + return self::normalize([ + $read ? self::READ : null, + $create ? self::CREATE : null, + $update ? self::UPDATE : null, + $delete ? self::DELETE : null, + ]); } public static function allowsToolRegistration(string $mode): bool { - return $mode !== self::NONE; + return self::normalize($mode) !== self::NONE; } public static function allowsCategory(string $mode, ?string $category): bool { + $mode = self::normalize($mode); if ($mode === self::FULL) { return true; } @@ -61,15 +112,6 @@ public static function allowsCategory(string $mode, ?string $category): bool return false; } - return match ($mode) { - self::READ => $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_READ, - self::CREATE => $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_READ - || $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_CREATE, - self::UPDATE => $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_READ - || $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_UPDATE, - self::DELETE => $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_READ - || $normalizedCategory === ApiMethodOperationClassifier::CATEGORY_DELETE, - default => false, - }; + return in_array($normalizedCategory, explode(',', $mode), true); } } diff --git a/SystemSettings.php b/SystemSettings.php index b266e89..2bf5061 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -19,11 +19,27 @@ class SystemSettings extends \Piwik\Settings\Plugin\SystemSettings { + private const RAW_API_ACCESS_SCOPE_NONE = 'none'; + private const RAW_API_ACCESS_SCOPE_PARTIAL = 'partial'; + private const RAW_API_ACCESS_SCOPE_FULL = 'full'; + /** @var Setting */ public $enableMcp; /** @var Setting */ - public $rawApiAccessMode; + public $rawApiAccessScope; + + /** @var Setting */ + public $rawApiAccessRead; + + /** @var Setting */ + public $rawApiAccessCreate; + + /** @var Setting */ + public $rawApiAccessUpdate; + + /** @var Setting */ + public $rawApiAccessDelete; protected function init(): void { @@ -47,31 +63,74 @@ function (FieldConfig $field) { }, ); - $this->rawApiAccessMode = $this->makeSetting( - 'raw_api_access_mode', - RawApiAccessMode::NONE, + $sharedRawApiInlineHelp = implode('

', [ + Piwik::translate('McpServer_RawApiAccessHelpPurpose'), + Piwik::translate('McpServer_RawApiAccessHelpReadFallback'), + Piwik::translate('McpServer_RawApiAccessHelpDataScope'), + Piwik::translate('McpServer_RawApiAccessHelpDestructive'), + Piwik::translate('McpServer_RawApiAccessHelpPolicy'), + ]); + + $this->rawApiAccessScope = $this->makeSetting( + 'raw_api_access_scope', + self::RAW_API_ACCESS_SCOPE_NONE, FieldConfig::TYPE_STRING, - function (FieldConfig $field) { - $field->title = Piwik::translate('McpServer_RawApiAccessModeTitle'); - $field->inlineHelp = implode('

', [ - Piwik::translate('McpServer_RawApiAccessModeHelpPurpose'), - Piwik::translate('McpServer_RawApiAccessModeHelpReadFallback'), - Piwik::translate('McpServer_RawApiAccessModeHelpDataScope'), - Piwik::translate('McpServer_RawApiAccessModeHelpDestructive'), - Piwik::translate('McpServer_RawApiAccessModeHelpPolicy'), - ]); + function (FieldConfig $field) use ($sharedRawApiInlineHelp) { + $field->title = Piwik::translate('McpServer_RawApiAccessScopeTitle'); + $field->inlineHelp = $sharedRawApiInlineHelp; $field->uiControl = FieldConfig::UI_CONTROL_SINGLE_SELECT; $field->condition = 'enable_mcp==1'; $field->availableValues = [ - RawApiAccessMode::NONE => Piwik::translate('McpServer_RawApiAccessModeOptionNone'), - RawApiAccessMode::READ => Piwik::translate('McpServer_RawApiAccessModeOptionRead'), - RawApiAccessMode::CREATE => Piwik::translate('McpServer_RawApiAccessModeOptionCreate'), - RawApiAccessMode::UPDATE => Piwik::translate('McpServer_RawApiAccessModeOptionUpdate'), - RawApiAccessMode::DELETE => Piwik::translate('McpServer_RawApiAccessModeOptionDelete'), - RawApiAccessMode::FULL => Piwik::translate('McpServer_RawApiAccessModeOptionFull'), + self::RAW_API_ACCESS_SCOPE_NONE => Piwik::translate('McpServer_RawApiAccessScopeNone'), + self::RAW_API_ACCESS_SCOPE_PARTIAL => Piwik::translate('McpServer_RawApiAccessScopePartial'), + self::RAW_API_ACCESS_SCOPE_FULL => Piwik::translate('McpServer_RawApiAccessScopeFull'), ]; }, ); + + $this->rawApiAccessRead = $this->makeSetting( + 'raw_api_access_read', + false, + FieldConfig::TYPE_BOOL, + function (FieldConfig $field) { + $field->title = Piwik::translate('McpServer_RawApiAccessReadTitle'); + $field->uiControl = FieldConfig::UI_CONTROL_CHECKBOX; + $field->condition = 'enable_mcp==1 && raw_api_access_scope=="partial"'; + }, + ); + + $this->rawApiAccessCreate = $this->makeSetting( + 'raw_api_access_create', + false, + FieldConfig::TYPE_BOOL, + function (FieldConfig $field) { + $field->title = Piwik::translate('McpServer_RawApiAccessCreateTitle'); + $field->uiControl = FieldConfig::UI_CONTROL_CHECKBOX; + $field->condition = 'enable_mcp==1 && raw_api_access_scope=="partial"'; + }, + ); + + $this->rawApiAccessUpdate = $this->makeSetting( + 'raw_api_access_update', + false, + FieldConfig::TYPE_BOOL, + function (FieldConfig $field) { + $field->title = Piwik::translate('McpServer_RawApiAccessUpdateTitle'); + $field->uiControl = FieldConfig::UI_CONTROL_CHECKBOX; + $field->condition = 'enable_mcp==1 && raw_api_access_scope=="partial"'; + }, + ); + + $this->rawApiAccessDelete = $this->makeSetting( + 'raw_api_access_delete', + false, + FieldConfig::TYPE_BOOL, + function (FieldConfig $field) { + $field->title = Piwik::translate('McpServer_RawApiAccessDeleteTitle'); + $field->uiControl = FieldConfig::UI_CONTROL_CHECKBOX; + $field->condition = 'enable_mcp==1 && raw_api_access_scope=="partial"'; + }, + ); } public function isMcpEnabled(): bool @@ -81,7 +140,40 @@ public function isMcpEnabled(): bool public function getRawApiAccessMode(): string { - return RawApiAccessMode::normalize($this->rawApiAccessMode->getValue()); + $scope = $this->normalizeRawApiAccessScope($this->rawApiAccessScope->getValue()); + if ($scope === self::RAW_API_ACCESS_SCOPE_FULL) { + return RawApiAccessMode::FULL; + } + + if ($scope !== self::RAW_API_ACCESS_SCOPE_PARTIAL) { + return RawApiAccessMode::NONE; + } + + return RawApiAccessMode::fromBooleans( + (bool) $this->rawApiAccessRead->getValue(), + (bool) $this->rawApiAccessCreate->getValue(), + (bool) $this->rawApiAccessUpdate->getValue(), + (bool) $this->rawApiAccessDelete->getValue(), + false, + ); + } + + private function normalizeRawApiAccessScope(mixed $scope): string + { + if (!is_scalar($scope)) { + return self::RAW_API_ACCESS_SCOPE_NONE; + } + + $normalizedScope = strtolower(trim((string) $scope)); + if ( + $normalizedScope !== self::RAW_API_ACCESS_SCOPE_NONE + && $normalizedScope !== self::RAW_API_ACCESS_SCOPE_PARTIAL + && $normalizedScope !== self::RAW_API_ACCESS_SCOPE_FULL + ) { + return self::RAW_API_ACCESS_SCOPE_NONE; + } + + return $normalizedScope; } private function getMcpEndpointUrl(): string diff --git a/docs/faq.md b/docs/faq.md index 2195d83..9acc8a5 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -29,13 +29,10 @@ log_tool_call_parameters_full = 0 Configure raw Matomo API tool access in **Administration -> System -> Plugin Settings -> McpServer**: -- Use the **Raw Matomo API tool access** setting to control visibility for `matomo_api_list`, `matomo_api_get`, and `matomo_api_call`. -- `Disabled`: hides `matomo_api_list`, `matomo_api_get`, and `matomo_api_call` (default). -- `Read access`: allows classified read methods. -- `Create access`: allows classified read and create methods. -- `Update access`: allows classified read and update methods. -- `Delete access`: allows classified read and delete methods. -- `Full API access`: allows direct API calls for all non-restricted methods, including state-changing or destructive methods. +- Use the **Raw Matomo API tool access** drop-down to control visibility for `matomo_api_list`, `matomo_api_get`, and `matomo_api_call`. +- `No API access` (default): hides all three raw API tools. +- `Partial API access`: shows all three tools. Use the **Read methods**, **Create methods**, **Update methods**, and **Delete methods** checkboxes to choose which CRUD categories are callable. Each checkbox is independent — selecting Create does not automatically include Read; check both if you want both. +- `Full API access`: shows all three tools and allows direct API calls for all non-restricted methods, including state-changing or destructive methods. - The dedicated report tools remain available independently of this setting. - Permanently restricted methods in `RawApiMethodPolicy` remain blocked in every mode. - Low-confidence or unclassified direct API methods require `Full API access`. diff --git a/lang/en.json b/lang/en.json index b378386..952aa89 100644 --- a/lang/en.json +++ b/lang/en.json @@ -32,17 +32,18 @@ "EnableMcpHelpPurpose": "Enable the Matomo MCP Server (Model Context Protocol) to allow AI tools and assistants to access analytics context from your Matomo instance.", "EnableMcpHelpUrl": "Your MCP URL: %1$s%2$s%3$s", "EnableMcpTitle": "Enable MCP Server (Model Context Protocol)", - "RawApiAccessModeHelpDataScope": "Direct Matomo API access can expose the same data available through the Matomo user interface or direct API endpoints, including raw or personal data when features such as the Visitor Log are enabled.", - "RawApiAccessModeHelpDestructive": "Create, update, delete, and full API access can execute state-changing or destructive API methods, including actions that modify configuration or delete data.", - "RawApiAccessModeHelpPolicy": "Before enabling direct API access, ensure this complies with your organization's privacy and security policies and applicable regulations. You may need approval from your data protection officer (DPO) or another compliance owner.", - "RawApiAccessModeHelpPurpose": "Control whether the direct raw Matomo API tools are hidden or exposed for classified read, create, update, delete, or full API access.", - "RawApiAccessModeHelpReadFallback": "Direct API access uses CRUD-style classification for discovered API methods. Dedicated report tools remain available independently, permanently restricted APIs stay blocked in every mode, and low-confidence methods require full access.", - "RawApiAccessModeOptionCreate": "Create access", - "RawApiAccessModeOptionDelete": "Delete access", - "RawApiAccessModeOptionFull": "Full API access", - "RawApiAccessModeOptionNone": "Disabled", - "RawApiAccessModeOptionRead": "Read access", - "RawApiAccessModeOptionUpdate": "Update access", - "RawApiAccessModeTitle": "Raw Matomo API tool access" + "RawApiAccessHelpDataScope": "Direct Matomo API access can expose the same data available through the Matomo user interface or direct API endpoints, including raw or personal data when features such as the Visitor Log are enabled.", + "RawApiAccessHelpDestructive": "Partial API access can enable create, update, and delete methods through the selected checkboxes below. Full API access can execute any allowed state-changing or destructive API methods, including actions that modify configuration or delete data.", + "RawApiAccessHelpPolicy": "Before enabling direct API access, ensure this complies with your organization's privacy and security policies and applicable regulations. You may need approval from your data protection officer (DPO) or another compliance owner.", + "RawApiAccessHelpPurpose": "Choose whether the direct raw Matomo API tools are hidden, exposed for partial API access, or exposed with full API access. When Partial API access is selected, use the checkboxes below to choose which CRUD categories are available.", + "RawApiAccessHelpReadFallback": "Direct API access uses CRUD-style classification for discovered API methods. In Partial API access mode, only the checked CRUD categories are available. Dedicated report tools remain available independently, permanently restricted APIs stay blocked in every mode, and low-confidence methods require Full API access.", + "RawApiAccessCreateTitle": "Create methods", + "RawApiAccessDeleteTitle": "Delete methods", + "RawApiAccessReadTitle": "Read methods", + "RawApiAccessScopeFull": "Full API access", + "RawApiAccessScopeNone": "No API access", + "RawApiAccessScopePartial": "Partial API access", + "RawApiAccessScopeTitle": "Raw Matomo API tool access", + "RawApiAccessUpdateTitle": "Update methods" } } diff --git a/tests/Framework/McpTestHelper.php b/tests/Framework/McpTestHelper.php index 3b54a0f..2f3535b 100644 --- a/tests/Framework/McpTestHelper.php +++ b/tests/Framework/McpTestHelper.php @@ -35,6 +35,7 @@ use Piwik\Access; use Piwik\Container\StaticContainer; use Piwik\Plugins\McpServer\McpServerFactory; +use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\SystemSettings; /** @@ -82,7 +83,25 @@ public static function setRawApiAccessMode(string $rawApiAccessMode): void $access->setSuperUserAccess(true); try { - StaticContainer::get(SystemSettings::class)->rawApiAccessMode->setValue($rawApiAccessMode); + $normalizedMode = RawApiAccessMode::normalize($rawApiAccessMode); + $systemSettings = StaticContainer::get(SystemSettings::class); + $systemSettings->rawApiAccessScope->setValue(match ($normalizedMode) { + RawApiAccessMode::FULL => 'full', + RawApiAccessMode::NONE => 'none', + default => 'partial', + }); + $systemSettings->rawApiAccessRead->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::READ), + ); + $systemSettings->rawApiAccessCreate->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::CREATE), + ); + $systemSettings->rawApiAccessUpdate->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::UPDATE), + ); + $systemSettings->rawApiAccessDelete->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::DELETE), + ); } finally { $access->setSuperUserAccess($hadSuperUserAccess); } diff --git a/tests/Integration/McpServerTest.php b/tests/Integration/McpServerTest.php index 6e948f3..f0f7899 100644 --- a/tests/Integration/McpServerTest.php +++ b/tests/Integration/McpServerTest.php @@ -15,6 +15,7 @@ use Piwik\Access; use Piwik\Container\StaticContainer; use Piwik\Plugin\Manager; +use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\SystemSettings; use Piwik\Plugins\McpServer\tests\Framework\McpAuthTestHelper; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; @@ -141,24 +142,47 @@ public function testContainerSystemSettingCanBeToggled(): void $systemSettings->enableMcp->setValue(true); self::assertTrue($systemSettings->isMcpEnabled()); - $systemSettings->rawApiAccessMode->setValue('read'); + $this->applyRawApiAccessMode($systemSettings, RawApiAccessMode::READ); self::assertSame('read', $systemSettings->getRawApiAccessMode()); - $systemSettings->rawApiAccessMode->setValue('create'); + $this->applyRawApiAccessMode($systemSettings, RawApiAccessMode::CREATE); self::assertSame('create', $systemSettings->getRawApiAccessMode()); - $systemSettings->rawApiAccessMode->setValue('update'); + $this->applyRawApiAccessMode($systemSettings, RawApiAccessMode::UPDATE); self::assertSame('update', $systemSettings->getRawApiAccessMode()); - $systemSettings->rawApiAccessMode->setValue('delete'); + $this->applyRawApiAccessMode($systemSettings, RawApiAccessMode::DELETE); self::assertSame('delete', $systemSettings->getRawApiAccessMode()); - $systemSettings->rawApiAccessMode->setValue('full'); + $this->applyRawApiAccessMode($systemSettings, RawApiAccessMode::FULL); self::assertSame('full', $systemSettings->getRawApiAccessMode()); } finally { $systemSettings->enableMcp->setValue($originalEnableMcpValue); - $systemSettings->rawApiAccessMode->setValue($originalRawApiAccessMode); + $this->applyRawApiAccessMode($systemSettings, $originalRawApiAccessMode); Access::getInstance()->setSuperUserAccess(false); } } + + private function applyRawApiAccessMode(SystemSettings $systemSettings, string $mode): void + { + $normalizedMode = RawApiAccessMode::normalize($mode); + + $systemSettings->rawApiAccessRead->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::READ), + ); + $systemSettings->rawApiAccessCreate->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::CREATE), + ); + $systemSettings->rawApiAccessUpdate->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::UPDATE), + ); + $systemSettings->rawApiAccessDelete->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::DELETE), + ); + $systemSettings->rawApiAccessScope->setValue(match ($normalizedMode) { + RawApiAccessMode::FULL => 'full', + RawApiAccessMode::NONE => 'none', + default => 'partial', + }); + } } diff --git a/tests/Integration/SystemSettingsTest.php b/tests/Integration/SystemSettingsTest.php index b6452eb..7645e8f 100644 --- a/tests/Integration/SystemSettingsTest.php +++ b/tests/Integration/SystemSettingsTest.php @@ -44,7 +44,7 @@ public function tearDown(): void try { $this->settings->enableMcp->setValue($this->originalEnableMcp); - $this->settings->rawApiAccessMode->setValue($this->originalRawApiAccessMode); + $this->applyRawApiAccessMode($this->originalRawApiAccessMode); } finally { Access::getInstance()->setSuperUserAccess($hadSuperUserAccess); } @@ -82,27 +82,55 @@ public function testRawApiAccessModeDefaultsToNone(): void public function testCanChangeRawApiAccessMode(): void { self::assertInstanceOf(SystemSettings::class, $this->settings); + $settings = $this->settings; $access = Access::getInstance(); $hadSuperUserAccess = $access->hasSuperUserAccess(); $access->setSuperUserAccess(true); try { - $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::READ); - self::assertSame(RawApiAccessMode::READ, $this->settings->getRawApiAccessMode()); + $this->applyRawApiAccessMode(RawApiAccessMode::READ); + self::assertSame(RawApiAccessMode::READ, $settings->getRawApiAccessMode()); - $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::CREATE); - self::assertSame(RawApiAccessMode::CREATE, $this->settings->getRawApiAccessMode()); + $this->applyRawApiAccessMode(RawApiAccessMode::CREATE); + self::assertSame(RawApiAccessMode::CREATE, $settings->getRawApiAccessMode()); - $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::UPDATE); - self::assertSame(RawApiAccessMode::UPDATE, $this->settings->getRawApiAccessMode()); + $this->applyRawApiAccessMode(RawApiAccessMode::UPDATE); + self::assertSame(RawApiAccessMode::UPDATE, $settings->getRawApiAccessMode()); - $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::DELETE); - self::assertSame(RawApiAccessMode::DELETE, $this->settings->getRawApiAccessMode()); + $this->applyRawApiAccessMode(RawApiAccessMode::DELETE); + self::assertSame(RawApiAccessMode::DELETE, $settings->getRawApiAccessMode()); - $this->settings->rawApiAccessMode->setValue(RawApiAccessMode::FULL); - self::assertSame(RawApiAccessMode::FULL, $this->settings->getRawApiAccessMode()); + $this->applyRawApiAccessMode(RawApiAccessMode::FULL); + self::assertSame(RawApiAccessMode::FULL, $settings->getRawApiAccessMode()); + + $this->applyRawApiAccessMode(RawApiAccessMode::READ . ',' . RawApiAccessMode::UPDATE); + self::assertSame(RawApiAccessMode::READ . ',' . RawApiAccessMode::UPDATE, $settings->getRawApiAccessMode()); } finally { $access->setSuperUserAccess($hadSuperUserAccess); } } + + private function applyRawApiAccessMode(string $mode): void + { + self::assertInstanceOf(SystemSettings::class, $this->settings); + $normalizedMode = RawApiAccessMode::normalize($mode); + + $this->settings->rawApiAccessRead->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::READ), + ); + $this->settings->rawApiAccessCreate->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::CREATE), + ); + $this->settings->rawApiAccessUpdate->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::UPDATE), + ); + $this->settings->rawApiAccessDelete->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::DELETE), + ); + $this->settings->rawApiAccessScope->setValue(match ($normalizedMode) { + RawApiAccessMode::FULL => 'full', + RawApiAccessMode::NONE => 'none', + default => 'partial', + }); + } } diff --git a/tests/UI/McpServer_spec.js b/tests/UI/McpServer_spec.js index 054dc93..2564cbe 100644 --- a/tests/UI/McpServer_spec.js +++ b/tests/UI/McpServer_spec.js @@ -14,7 +14,7 @@ describe('McpServer', function () { const connectUrl = '?module=McpServer&action=connect&idSite=1&period=day&date=yesterday'; const settingsSelector = '#McpServerPluginSettings'; const enabledCheckboxSelector = 'input[name="enable_mcp"]'; - const rawApiAccessModeSelector = 'select[name="raw_api_access_mode"]'; + const rawApiAccessScopeSelector = 'select[name="raw_api_access_scope"]'; const settingsSaveButtonSelector = `${settingsSelector} .pluginsSettingsSubmit`; const connectSelector = '.mcpServerConnect'; @@ -55,7 +55,7 @@ describe('McpServer', function () { await page.waitForNetworkIdle(); } - async function isRawApiAccessModeVisible() + async function isRawApiAccessScopeVisible() { return page.evaluate((selector) => { const element = document.querySelector(selector); @@ -65,10 +65,10 @@ describe('McpServer', function () { } return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); - }, rawApiAccessModeSelector); + }, rawApiAccessScopeSelector); } - async function configureMcp(enabled, rawApiAccessMode = 'string:read') + async function configureMcp(enabled, rawApiAccessScope = 'string:partial', rawApiAccessLevels = []) { resetUserToSuperUser(); await page.goto(settingsUrl); @@ -86,14 +86,30 @@ describe('McpServer', function () { } if (enabled) { - await page.waitForSelector(rawApiAccessModeSelector, { visible: true }); - const currentRawApiAccessMode = await page.$eval(rawApiAccessModeSelector, (el) => el.value); + await page.waitForSelector(rawApiAccessScopeSelector, { visible: true }); + const currentRawApiAccessScope = await page.$eval(rawApiAccessScopeSelector, (el) => el.value); - if (currentRawApiAccessMode !== rawApiAccessMode) { - await page.select(rawApiAccessModeSelector, rawApiAccessMode); + if (currentRawApiAccessScope !== rawApiAccessScope) { + await page.select(rawApiAccessScopeSelector, rawApiAccessScope); await page.waitForTimeout(250); await saveSettings(); } + + if (rawApiAccessScope === 'string:partial') { + for (const level of ['read', 'create', 'update', 'delete']) { + const selector = `input[name="raw_api_access_${level}"]`; + const shouldBeEnabled = rawApiAccessLevels.includes(level); + + await page.waitForSelector(selector, { visible: true }); + const isEnabled = await page.$eval(selector, (el) => !!el.checked); + + if (isEnabled !== shouldBeEnabled) { + await page.click(`${selector} + span`); + await page.waitForTimeout(250); + await saveSettings(); + } + } + } } await page.goto(settingsUrl); @@ -124,14 +140,15 @@ describe('McpServer', function () { await configureMcp(false); expect(await page.$eval(enabledCheckboxSelector, (el) => !!el.checked)).to.equal(false); - expect(await isRawApiAccessModeVisible()).to.equal(false); + expect(await isRawApiAccessScopeVisible()).to.equal(false); }); - it('should display the plugin settings when MCP is enabled with read-only API access', async function () { - await configureMcp(true, 'string:read'); + it('should display the plugin settings when MCP is enabled with partial API access', async function () { + await configureMcp(true, 'string:partial', ['read']); - expect(await isRawApiAccessModeVisible()).to.equal(true); - expect(await page.$eval(rawApiAccessModeSelector, (el) => el.value)).to.equal('string:read'); + expect(await isRawApiAccessScopeVisible()).to.equal(true); + expect(await page.$eval(rawApiAccessScopeSelector, (el) => el.value)).to.equal('string:partial'); + expect(await page.$eval('input[name="raw_api_access_read"]', (el) => !!el.checked)).to.equal(true); expect(await page.screenshotSelector(settingsSelector)).to.matchImage('settings'); }); @@ -171,7 +188,7 @@ describe('McpServer', function () { }); it('should display the connect page when MCP is enabled', async function () { - await configureMcp(true, 'string:read'); + await configureMcp(true, 'string:full'); await page.goto(connectUrl); await page.waitForNetworkIdle(); await page.waitForSelector(connectSelector, { visible: true }); diff --git a/tests/Unit/McpTools/ApiListTest.php b/tests/Unit/McpTools/ApiListTest.php index cb58965..8762766 100644 --- a/tests/Unit/McpTools/ApiListTest.php +++ b/tests/Unit/McpTools/ApiListTest.php @@ -17,6 +17,7 @@ use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryQueryRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; use Piwik\Plugins\McpServer\McpTools\ApiList; +use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; use Piwik\Plugins\McpServer\Support\Pagination\ApiMethodsPagination; use Piwik\Plugins\McpServer\Support\Pagination\CursorPaginator; @@ -433,14 +434,7 @@ public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array return array_values(array_filter( $records, static function (ApiMethodSummaryRecord $record) use ($query): bool { - if ($query->accessMode === 'read' && $record->operationCategory !== 'read') { - return false; - } - - if ( - $query->accessMode === 'create' - && !in_array($record->operationCategory, ['read', 'create'], true) - ) { + if (!RawApiAccessMode::allowsCategory($query->accessMode, $record->operationCategory)) { return false; } diff --git a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php index 32e3b11..52610d4 100644 --- a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php +++ b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php @@ -170,7 +170,7 @@ public function testFilterRecordsAppliesReadAccessMode(): void ); } - public function testFilterRecordsUsesCrudModesForClassifiedMethods(): void + public function testFilterRecordsUsesExplicitCrudModesForClassifiedMethods(): void { $service = new ApiMethodSummaryQueryService(); @@ -192,6 +192,10 @@ public function testFilterRecordsUsesCrudModesForClassifiedMethods(): void $this->createMethodRecords(), ApiMethodSummaryQueryRecord::fromInputs('create'), ); + $readCreateRecords = $service->filterRecords( + $this->createMethodRecords(), + ApiMethodSummaryQueryRecord::fromInputs('read,create'), + ); $fullRecords = $service->filterRecords( [ new ApiMethodSummaryRecord( @@ -211,6 +215,13 @@ public function testFilterRecordsUsesCrudModesForClassifiedMethods(): void ['UsersManager.hasSuperUserAccess'], array_values(array_map(static fn(ApiMethodSummaryRecord $record): string => $record->method, $readRecords)), ); + self::assertSame( + ['UsersManager.addUser'], + array_values(array_map( + static fn(ApiMethodSummaryRecord $record): string => $record->method, + $createRecords, + )), + ); self::assertSame( [ 'API.getMatomoVersion', @@ -220,7 +231,7 @@ public function testFilterRecordsUsesCrudModesForClassifiedMethods(): void ], array_values(array_map( static fn(ApiMethodSummaryRecord $record): string => $record->method, - $createRecords, + $readCreateRecords, )), ); self::assertSame( diff --git a/tests/Unit/Support/Access/RawApiAccessModeTest.php b/tests/Unit/Support/Access/RawApiAccessModeTest.php index bfadb84..833a877 100644 --- a/tests/Unit/Support/Access/RawApiAccessModeTest.php +++ b/tests/Unit/Support/Access/RawApiAccessModeTest.php @@ -36,9 +36,24 @@ public function testNormalizeAcceptsSupportedValuesCaseInsensitively(): void self::assertSame(RawApiAccessMode::UPDATE, RawApiAccessMode::normalize('update')); self::assertSame(RawApiAccessMode::DELETE, RawApiAccessMode::normalize(' Delete ')); self::assertSame(RawApiAccessMode::FULL, RawApiAccessMode::normalize('FULL')); + self::assertSame( + RawApiAccessMode::READ . ',' . RawApiAccessMode::UPDATE, + RawApiAccessMode::normalize(' update, read '), + ); + } + + public function testFromBooleansReturnsCanonicalModes(): void + { + self::assertSame(RawApiAccessMode::NONE, RawApiAccessMode::fromBooleans(false, false, false, false, false)); + self::assertSame(RawApiAccessMode::READ, RawApiAccessMode::fromBooleans(true, false, false, false, false)); + self::assertSame( + RawApiAccessMode::READ . ',' . RawApiAccessMode::UPDATE, + RawApiAccessMode::fromBooleans(true, false, true, false, false), + ); + self::assertSame(RawApiAccessMode::FULL, RawApiAccessMode::fromBooleans(false, false, false, false, true)); } - public function testAllowsCategoryUsesCrudModeInheritance(): void + public function testAllowsCategoryUsesExplicitCrudSelection(): void { self::assertTrue( RawApiAccessMode::allowsCategory(RawApiAccessMode::FULL, ApiMethodOperationClassifier::CATEGORY_DELETE), @@ -51,12 +66,12 @@ public function testAllowsCategoryUsesCrudModeInheritance(): void RawApiAccessMode::allowsCategory(RawApiAccessMode::READ, ApiMethodOperationClassifier::CATEGORY_CREATE), ); - self::assertTrue( - RawApiAccessMode::allowsCategory(RawApiAccessMode::CREATE, ApiMethodOperationClassifier::CATEGORY_READ), - ); self::assertTrue( RawApiAccessMode::allowsCategory(RawApiAccessMode::CREATE, ApiMethodOperationClassifier::CATEGORY_CREATE), ); + self::assertFalse( + RawApiAccessMode::allowsCategory(RawApiAccessMode::CREATE, ApiMethodOperationClassifier::CATEGORY_READ), + ); self::assertFalse( RawApiAccessMode::allowsCategory(RawApiAccessMode::CREATE, ApiMethodOperationClassifier::CATEGORY_UPDATE), ); @@ -64,9 +79,21 @@ public function testAllowsCategoryUsesCrudModeInheritance(): void self::assertTrue( RawApiAccessMode::allowsCategory(RawApiAccessMode::UPDATE, ApiMethodOperationClassifier::CATEGORY_UPDATE), ); + self::assertFalse( + RawApiAccessMode::allowsCategory(RawApiAccessMode::UPDATE, ApiMethodOperationClassifier::CATEGORY_READ), + ); self::assertTrue( RawApiAccessMode::allowsCategory(RawApiAccessMode::DELETE, ApiMethodOperationClassifier::CATEGORY_DELETE), ); + self::assertFalse( + RawApiAccessMode::allowsCategory(RawApiAccessMode::DELETE, ApiMethodOperationClassifier::CATEGORY_READ), + ); + self::assertTrue( + RawApiAccessMode::allowsCategory('read,update', ApiMethodOperationClassifier::CATEGORY_READ), + ); + self::assertTrue( + RawApiAccessMode::allowsCategory('read,update', ApiMethodOperationClassifier::CATEGORY_UPDATE), + ); self::assertFalse(RawApiAccessMode::allowsCategory(RawApiAccessMode::NONE, 'read')); self::assertFalse(RawApiAccessMode::allowsCategory(RawApiAccessMode::READ, null)); } diff --git a/tests/Unit/Support/Access/RawApiMethodPolicyTest.php b/tests/Unit/Support/Access/RawApiMethodPolicyTest.php index 78f0187..54ae0cf 100644 --- a/tests/Unit/Support/Access/RawApiMethodPolicyTest.php +++ b/tests/Unit/Support/Access/RawApiMethodPolicyTest.php @@ -22,7 +22,7 @@ */ class RawApiMethodPolicyTest extends TestCase { - public function testAllowsMethodUsesCrudClassificationForNonFullModes(): void + public function testAllowsMethodUsesExplicitCrudClassificationForNonFullModes(): void { self::assertTrue( RawApiMethodPolicy::allowsMethod( @@ -78,6 +78,15 @@ public function testAllowsMethodUsesCrudClassificationForNonFullModes(): void ApiMethodOperationClassifier::CONFIDENCE_MEDIUM, ), ); + self::assertFalse( + RawApiMethodPolicy::allowsMethod( + RawApiAccessMode::UPDATE, + 'UsersManager.getUsers', + 'getUsers', + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + ), + ); self::assertFalse( RawApiMethodPolicy::allowsMethod( RawApiAccessMode::UPDATE, @@ -87,6 +96,15 @@ public function testAllowsMethodUsesCrudClassificationForNonFullModes(): void ApiMethodOperationClassifier::CONFIDENCE_HIGH, ), ); + self::assertTrue( + RawApiMethodPolicy::allowsMethod( + RawApiAccessMode::READ . ',' . RawApiAccessMode::UPDATE, + 'UsersManager.getUsers', + 'getUsers', + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + ), + ); } public function testAllowsMethodLetsFullModeUseNonDeniedMethods(): void From 05679827751b7240b6fad1fb6a5eb258396a5cb3 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Wed, 8 Apr 2026 22:44:05 +0200 Subject: [PATCH 10/15] Split matomo_api_get into separate CRUD-style tools --- .../Api/ApiCallQueryServiceInterface.php | 6 +- McpServerFactory.php | 148 ++++++++++++++---- McpTools/{ApiCall.php => AbstractApiCall.php} | 29 +++- McpTools/ApiCallCreate.php | 27 ++++ McpTools/ApiCallDelete.php | 27 ++++ McpTools/ApiCallFull.php | 22 +++ McpTools/ApiCallRead.php | 27 ++++ McpTools/ApiCallUpdate.php | 27 ++++ Services/Api/ApiCallQueryService.php | 19 +-- config/config.php | 12 +- docs/faq.md | 8 +- tests/Integration/McpTools/ApiCallTest.php | 105 ++++++++++--- .../McpToolsContractBaselineTest.php | 4 +- tests/Integration/McpToolsContractTest.php | 61 ++++++-- tests/Unit/McpServerFactoryTest.php | 49 ++++-- tests/Unit/McpTools/ApiCallTest.php | 119 +++++++++----- .../Unit/Services/ApiCallQueryServiceTest.php | 125 +++------------ 17 files changed, 570 insertions(+), 245 deletions(-) rename McpTools/{ApiCall.php => AbstractApiCall.php} (51%) create mode 100644 McpTools/ApiCallCreate.php create mode 100644 McpTools/ApiCallDelete.php create mode 100644 McpTools/ApiCallFull.php create mode 100644 McpTools/ApiCallRead.php create mode 100644 McpTools/ApiCallUpdate.php diff --git a/Contracts/Ports/Api/ApiCallQueryServiceInterface.php b/Contracts/Ports/Api/ApiCallQueryServiceInterface.php index c9cb824..5cf09b8 100644 --- a/Contracts/Ports/Api/ApiCallQueryServiceInterface.php +++ b/Contracts/Ports/Api/ApiCallQueryServiceInterface.php @@ -12,6 +12,7 @@ namespace Piwik\Plugins\McpServer\Contracts\Ports\Api; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiCallRecord; +use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; interface ApiCallQueryServiceInterface { @@ -19,10 +20,7 @@ interface ApiCallQueryServiceInterface * @param array|null $parameters */ public function callApi( - string $accessMode, - ?string $method = null, - ?string $module = null, - ?string $action = null, + ApiMethodSummaryRecord $resolvedMethod, ?array $parameters = null, ): ApiCallRecord; } diff --git a/McpServerFactory.php b/McpServerFactory.php index ed82369..5599c4c 100644 --- a/McpServerFactory.php +++ b/McpServerFactory.php @@ -16,12 +16,17 @@ use Matomo\Dependencies\McpServer\Mcp\Schema\ServerCapabilities; use Matomo\Dependencies\McpServer\Mcp\Schema\ToolAnnotations; use Matomo\Dependencies\McpServer\Mcp\Server; +use Matomo\Dependencies\McpServer\Mcp\Server\Builder; use Matomo\Dependencies\McpServer\Mcp\Server\Handler\Request\CallToolHandler; use Matomo\Dependencies\McpServer\Mcp\Server\Session\SessionStoreInterface; use Piwik\Config; use Piwik\Log\LoggerInterface; use Piwik\Plugin\Manager; -use Piwik\Plugins\McpServer\McpTools\ApiCall; +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\Schemas\Api\ApiCallToolInputSchema; @@ -79,33 +84,7 @@ public function createServer(): Server $rawApiAccessMode = $this->systemSettings->getRawApiAccessMode(); if (RawApiAccessMode::allowsToolRegistration($rawApiAccessMode)) { - $rawApiCallDestructiveHint = $rawApiAccessMode === RawApiAccessMode::FULL; - $builder->addTool( - [ApiCall::class, 'call'], - ApiCall::TOOL_NAME, - "Use when: you need to execute a known Matomo API method directly.\n" - . "Purpose: call one allowed API method and return its result plus the resolved method metadata.\n" - . "Next: use " . ApiGet::TOOL_NAME . ' or ' . ApiList::TOOL_NAME - . ' first if you still need to confirm the method signature.', - // Keep these conservative defaults for raw API calls: - // - readOnlyHint=false because even "read" API methods can trigger - // archive/materialization side effects depending on Matomo runtime config. - // - destructiveHint=false in read mode because those effects are additive, - // not destructive mutations; full mode remains destructive because it can - // call arbitrary mutating methods. - // - idempotentHint=false because repeated identical calls cannot guarantee - // zero additional environmental effect across archive configurations. - new ToolAnnotations( - readOnlyHint: false, - destructiveHint: $rawApiCallDestructiveHint, - idempotentHint: false, - openWorldHint: false, - ), - ApiCallToolInputSchema::SCHEMA, - null, - null, - ApiCallToolOutputSchema::ITEM, - ); + $this->registerRawApiCallTools($builder, $rawApiAccessMode); // This tool is registered manually (not via attribute discovery) // so registration can be gated by the raw API access mode. $builder->addTool( @@ -159,6 +138,119 @@ public function createServer(): Server return $builder->build(); } + private function registerRawApiCallTools(Builder $builder, string $rawApiAccessMode): void + { + if (RawApiAccessMode::allowsCategory($rawApiAccessMode, RawApiAccessMode::READ)) { + $builder->addTool( + [ApiCallRead::class, 'call'], + ApiCallRead::TOOL_NAME, + "Use when: you need to execute a known read-only Matomo API method directly.\n" + . "Purpose: call one allowed read method and return its result plus the resolved method metadata.\n" + . "Next: use " . ApiGet::TOOL_NAME . ' or ' . ApiList::TOOL_NAME + . ' first if you still need to confirm the method signature.', + new ToolAnnotations( + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + ), + ApiCallToolInputSchema::SCHEMA, + null, + null, + ApiCallToolOutputSchema::ITEM, + ); + } + + if (RawApiAccessMode::allowsCategory($rawApiAccessMode, RawApiAccessMode::CREATE)) { + $builder->addTool( + [ApiCallCreate::class, 'call'], + ApiCallCreate::TOOL_NAME, + "Use when: you need to execute a known create-style Matomo API method directly.\n" + . "Purpose: call one allowed create method and return its result plus the" + . " resolved method metadata.\n" + . "Next: use " . ApiGet::TOOL_NAME . ' or ' . ApiList::TOOL_NAME + . ' first if you still need to confirm the method signature.', + new ToolAnnotations( + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + ), + ApiCallToolInputSchema::SCHEMA, + null, + null, + ApiCallToolOutputSchema::ITEM, + ); + } + + if (RawApiAccessMode::allowsCategory($rawApiAccessMode, RawApiAccessMode::UPDATE)) { + $builder->addTool( + [ApiCallUpdate::class, 'call'], + ApiCallUpdate::TOOL_NAME, + "Use when: you need to execute a known update-style Matomo API method directly.\n" + . "Purpose: call one allowed update method and return its result plus the" + . " resolved method metadata.\n" + . "Next: use " . ApiGet::TOOL_NAME . ' or ' . ApiList::TOOL_NAME + . ' first if you still need to confirm the method signature.', + new ToolAnnotations( + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + ), + ApiCallToolInputSchema::SCHEMA, + null, + null, + ApiCallToolOutputSchema::ITEM, + ); + } + + if (RawApiAccessMode::allowsCategory($rawApiAccessMode, RawApiAccessMode::DELETE)) { + $builder->addTool( + [ApiCallDelete::class, 'call'], + ApiCallDelete::TOOL_NAME, + "Use when: you need to execute a known delete-style Matomo API method directly.\n" + . "Purpose: call one allowed delete method and return its result plus the" + . " resolved method metadata.\n" + . "Next: use " . ApiGet::TOOL_NAME . ' or ' . ApiList::TOOL_NAME + . ' first if you still need to confirm the method signature.', + new ToolAnnotations( + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + ), + ApiCallToolInputSchema::SCHEMA, + null, + null, + ApiCallToolOutputSchema::ITEM, + ); + } + + if ($rawApiAccessMode === RawApiAccessMode::FULL) { + $builder->addTool( + [ApiCallFull::class, 'call'], + ApiCallFull::TOOL_NAME, + "Use when: you need to execute a known Matomo API method directly and" + . " it is not safely covered by one CRUD-specific tool.\n" + . "Purpose: call one allowed full-access API method and return its result" + . " plus the resolved method metadata.\n" + . "Next: prefer CRUD-specific raw API call tools when the method" + . " classification is known.", + new ToolAnnotations( + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + ), + ApiCallToolInputSchema::SCHEMA, + null, + null, + ApiCallToolOutputSchema::ITEM, + ); + } + } + /** * @return array{logToolCalls: bool, logFullParameters: bool, logLevel: string} */ diff --git a/McpTools/ApiCall.php b/McpTools/AbstractApiCall.php similarity index 51% rename from McpTools/ApiCall.php rename to McpTools/AbstractApiCall.php index 3a7f38c..51cf564 100644 --- a/McpTools/ApiCall.php +++ b/McpTools/AbstractApiCall.php @@ -11,19 +11,22 @@ namespace Piwik\Plugins\McpServer\McpTools; +use Matomo\Dependencies\McpServer\Mcp\Exception\ToolCallException; use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiCallQueryServiceInterface; +use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiMethodSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiCallRecord; use Piwik\Plugins\McpServer\SystemSettings; /** * @phpstan-import-type ApiCallArray from ApiCallRecord */ -class ApiCall +abstract class AbstractApiCall { - public const TOOL_NAME = 'matomo_api_call'; + private const UNAVAILABLE_MESSAGE = 'API method not found or unavailable.'; public function __construct( private ApiCallQueryServiceInterface $queryService, + private ApiMethodSummaryQueryServiceInterface $apiMethodSummaryQueryService, private SystemSettings $systemSettings, ) { } @@ -38,12 +41,30 @@ public function call( ?string $action = null, ?array $parameters = null, ): array { - return $this->queryService->callApi( - $this->systemSettings->getRawApiAccessMode(), + $accessMode = $this->systemSettings->getRawApiAccessMode(); + $resolvedMethod = $this->apiMethodSummaryQueryService->getApiMethodSummaryBySelector( + $accessMode, $method, $module, $action, + ); + + $expectedOperationCategory = $this->getExpectedOperationCategory(); + if ( + $expectedOperationCategory !== null + && $resolvedMethod->operationCategory !== $expectedOperationCategory + ) { + throw new ToolCallException(self::UNAVAILABLE_MESSAGE); + } + + return $this->queryService->callApi( + $resolvedMethod, $parameters, )->toArray(); } + + /** + * @return ?non-empty-string + */ + abstract protected function getExpectedOperationCategory(): ?string; } diff --git a/McpTools/ApiCallCreate.php b/McpTools/ApiCallCreate.php new file mode 100644 index 0000000..2acf0dd --- /dev/null +++ b/McpTools/ApiCallCreate.php @@ -0,0 +1,27 @@ + true, ]; - public function __construct( - private ApiMethodSummaryQueryServiceInterface $apiMethodSummaryQueryService, - private CoreApiCallGatewayInterface $coreApiCallGateway, - ) { + public function __construct(private CoreApiCallGatewayInterface $coreApiCallGateway) + { } public function callApi( - string $accessMode, - ?string $method = null, - ?string $module = null, - ?string $action = null, + ApiMethodSummaryRecord $resolvedMethod, ?array $parameters = null, ): ApiCallRecord { - $resolvedMethod = $this->apiMethodSummaryQueryService->getApiMethodSummaryBySelector( - $accessMode, - $method, - $module, - $action, - ); $sanitizedParameters = $this->sanitizeParameters($parameters); try { diff --git a/config/config.php b/config/config.php index 4066302..00a4c02 100644 --- a/config/config.php +++ b/config/config.php @@ -30,7 +30,11 @@ use Piwik\Plugins\McpServer\Contracts\Ports\Sites\SiteSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; use Piwik\Plugins\McpServer\McpServerFactory; -use Piwik\Plugins\McpServer\McpTools\ApiCall; +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\Services\Api\ApiCallQueryService; @@ -89,7 +93,11 @@ DbSessionStore::class => DI::autowire(), McpServerFactory::class => DI::autowire(), PaginatedCollectionResponder::class => DI::autowire(), - ApiCall::class => DI::autowire(), + ApiCallCreate::class => DI::autowire(), + ApiCallDelete::class => DI::autowire(), + ApiCallFull::class => DI::autowire(), + ApiCallRead::class => DI::autowire(), + ApiCallUpdate::class => DI::autowire(), ApiGet::class => DI::autowire(), ApiList::class => DI::autowire(), Tasks::class => DI::autowire(), diff --git a/docs/faq.md b/docs/faq.md index 9acc8a5..e3eef84 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -29,10 +29,10 @@ log_tool_call_parameters_full = 0 Configure raw Matomo API tool access in **Administration -> System -> Plugin Settings -> McpServer**: -- Use the **Raw Matomo API tool access** drop-down to control visibility for `matomo_api_list`, `matomo_api_get`, and `matomo_api_call`. -- `No API access` (default): hides all three raw API tools. -- `Partial API access`: shows all three tools. Use the **Read methods**, **Create methods**, **Update methods**, and **Delete methods** checkboxes to choose which CRUD categories are callable. Each checkbox is independent — selecting Create does not automatically include Read; check both if you want both. -- `Full API access`: shows all three tools and allows direct API calls for all non-restricted methods, including state-changing or destructive methods. +- Use the **Raw Matomo API tool access** drop-down to control visibility for `matomo_api_list`, `matomo_api_get`, and the raw API call tools. +- `No API access` (default): hides all raw API discovery and execution tools. +- `Partial API access`: shows `matomo_api_get`, `matomo_api_list`, and only the CRUD-specific execution tools enabled by the **Read methods**, **Create methods**, **Update methods**, and **Delete methods** checkboxes. Each checkbox is independent — selecting Create does not automatically include Read; check both if you want both. +- `Full API access`: shows `matomo_api_get`, `matomo_api_list`, all CRUD-specific execution tools, and `matomo_api_call_full` for non-restricted methods that need unrestricted execution. - The dedicated report tools remain available independently of this setting. - Permanently restricted methods in `RawApiMethodPolicy` remain blocked in every mode. - Low-confidence or unclassified direct API methods require `Full API access`. diff --git a/tests/Integration/McpTools/ApiCallTest.php b/tests/Integration/McpTools/ApiCallTest.php index 546fae0..7450e55 100644 --- a/tests/Integration/McpTools/ApiCallTest.php +++ b/tests/Integration/McpTools/ApiCallTest.php @@ -15,7 +15,11 @@ use Piwik\API\Request; use Piwik\DataTable\DataTableInterface; use Piwik\DataTable\Renderer\Json; -use Piwik\Plugins\McpServer\McpTools\ApiCall; +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\tests\Framework\McpTestHelper; use Piwik\Tests\Framework\Fixture; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -76,7 +80,7 @@ public function testReadModeCallsKnownReadMethodByMethodSelector(): void $content = McpTestHelper::callToolAndAssertSuccess( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallRead::TOOL_NAME, ['method' => ' API.getMatomoVersion '], __METHOD__, ); @@ -103,7 +107,7 @@ public function testReadModeCallsKnownReadMethodByModuleAndActionSelector(): voi $content = McpTestHelper::callToolAndAssertSuccess( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallRead::TOOL_NAME, ['module' => ' API ', 'action' => ' getMatomoVersion '], __METHOD__, ); @@ -136,7 +140,7 @@ public function testReadModeNormalizesDataTableResponse(): void $content = McpTestHelper::callToolAndAssertSuccess( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallRead::TOOL_NAME, [ 'method' => 'Actions.getPageUrls', 'parameters' => [ @@ -160,7 +164,7 @@ public function testReadModeRejectsWriteOnlyMethod(): void McpTestHelper::callToolAndAssertError( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallRead::TOOL_NAME, ['method' => 'UsersManager.addUser'], 'API method not found or unavailable.', __METHOD__, @@ -176,7 +180,7 @@ public function testReadModeCallsMediumConfidenceReadMethod(): void $content = McpTestHelper::callToolAndAssertSuccess( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallRead::TOOL_NAME, ['method' => 'UsersManager.hasSuperUserAccess'], __METHOD__, ); @@ -198,7 +202,7 @@ public function testFullModeCallsKnownMediumConfidenceReadMethod(): void $content = McpTestHelper::callToolAndAssertSuccess( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallFull::TOOL_NAME, ['method' => 'UsersManager.hasSuperUserAccess'], __METHOD__, ); @@ -221,7 +225,7 @@ public function testReadModeRejectsBlockedProxyLikeMethod(): void McpTestHelper::callToolAndAssertError( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallRead::TOOL_NAME, ['method' => 'API.getBulkRequest'], 'API method not found or unavailable.', __METHOD__, @@ -237,7 +241,7 @@ public function testReadModeRejectsGetMetadata(): void McpTestHelper::callToolAndAssertError( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallRead::TOOL_NAME, ['method' => 'API.getMetadata'], 'API method not found or unavailable.', __METHOD__, @@ -253,7 +257,7 @@ public function testReadModeRejectsGetReportMetadata(): void McpTestHelper::callToolAndAssertError( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallRead::TOOL_NAME, ['method' => 'API.getReportMetadata'], 'API method not found or unavailable.', __METHOD__, @@ -269,7 +273,7 @@ public function testFullModeRejectsBlockedProxyLikeMethodBySplitSelector(): void McpTestHelper::callToolAndAssertError( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallFull::TOOL_NAME, ['module' => 'Insights', 'action' => 'getInsights'], 'API method not found or unavailable.', __METHOD__, @@ -285,7 +289,7 @@ public function testFullModeAttemptsMutatingMethodCall(): void $result = McpTestHelper::callTool( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallFull::TOOL_NAME, ['method' => 'UsersManager.addUser'], __METHOD__, ); @@ -310,7 +314,7 @@ public function testCreateModeAttemptsCreateMethodCall(): void $result = McpTestHelper::callTool( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallCreate::TOOL_NAME, ['method' => 'UsersManager.addUser'], __METHOD__, ); @@ -329,7 +333,7 @@ public function testDeleteModeAttemptsDeleteMethodCall(): void $result = McpTestHelper::callTool( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallDelete::TOOL_NAME, ['method' => 'SitesManager.deleteSite'], __METHOD__, ); @@ -345,6 +349,31 @@ public function testDeleteModeAttemptsDeleteMethodCall(): void ); } + public function testUpdateModeAttemptsUpdateMethodCall(): void + { + McpTestHelper::setRawApiAccessMode('update'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $result = McpTestHelper::callTool( + $server, + $sessionId, + ApiCallUpdate::TOOL_NAME, + ['method' => 'UsersManager.updateUser'], + __METHOD__, + ); + + McpTestHelper::assertToolError($result); + $content = $result->content[0] ?? null; + self::assertInstanceOf(\Matomo\Dependencies\McpServer\Mcp\Schema\Content\TextContent::class, $content); + $errorText = $content->text; + self::assertIsString($errorText); + self::assertTrue( + $errorText === 'Matomo API request failed.' + || str_starts_with($errorText, 'Matomo API request failed: '), + ); + } + public function testRejectsReservedParameterKeys(): void { McpTestHelper::setRawApiAccessMode('read'); @@ -354,7 +383,7 @@ public function testRejectsReservedParameterKeys(): void McpTestHelper::callToolAndAssertError( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallRead::TOOL_NAME, [ 'method' => 'API.getMatomoVersion', 'parameters' => ['format' => 'json'], @@ -373,13 +402,13 @@ public function testRejectsMissingSelectorAtSchemaLevel(): void $message = McpTestHelper::callToolExpectInvalidParams( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallFull::TOOL_NAME, [], __METHOD__, ); self::assertStringContainsString( - "Invalid parameters for tool '" . ApiCall::TOOL_NAME . "':", + "Invalid parameters for tool '" . ApiCallFull::TOOL_NAME . "':", $message->message, ); } @@ -391,7 +420,7 @@ public function testRejectsMixedSelectorStyleAtSchemaLevel(): void $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest( - ApiCall::TOOL_NAME, + ApiCallFull::TOOL_NAME, ['method' => 'API.getMatomoVersion', 'module' => 'API'], __METHOD__, ); @@ -401,7 +430,7 @@ public function testRejectsMixedSelectorStyleAtSchemaLevel(): void self::assertSame(JsonRpcError::INVALID_PARAMS, $message->code); self::assertStringContainsString( - "Invalid parameters for tool '" . ApiCall::TOOL_NAME . "':", + "Invalid parameters for tool '" . ApiCallFull::TOOL_NAME . "':", $message->message ?? '', ); } @@ -420,7 +449,7 @@ public function testSchemaDeclaresFlatSelectorsWithoutTopLevelCombinators(): voi $apiCallTool = null; foreach ($result->tools as $tool) { - if ($tool->name === ApiCall::TOOL_NAME) { + if ($tool->name === ApiCallFull::TOOL_NAME) { $apiCallTool = $tool; break; } @@ -439,12 +468,12 @@ public function testSchemaDeclaresFlatSelectorsWithoutTopLevelCombinators(): voi public function testNoneModeHidesAndRejectsToolCall(): void { McpTestHelper::setRawApiAccessMode('none'); - self::assertNotContains(ApiCall::TOOL_NAME, $this->listToolNamesForCurrentConfig()); + self::assertNotContains(ApiCallRead::TOOL_NAME, $this->listToolNamesForCurrentConfig()); $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeCallToolRequest( - ApiCall::TOOL_NAME, + ApiCallRead::TOOL_NAME, ['method' => 'API.getMatomoVersion'], __METHOD__, ); @@ -454,6 +483,38 @@ public function testNoneModeHidesAndRejectsToolCall(): void self::assertSame(JsonRpcError::METHOD_NOT_FOUND, $message->code); } + public function testDeleteToolRejectsReadMethodInFullMode(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCallDelete::TOOL_NAME, + ['method' => 'API.getMatomoVersion'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testUpdateToolRejectsReadMethodInFullMode(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCallUpdate::TOOL_NAME, + ['method' => 'API.getMatomoVersion'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + /** * @return list */ diff --git a/tests/Integration/McpToolsContractBaselineTest.php b/tests/Integration/McpToolsContractBaselineTest.php index 4f698be..a9fac8a 100644 --- a/tests/Integration/McpToolsContractBaselineTest.php +++ b/tests/Integration/McpToolsContractBaselineTest.php @@ -15,7 +15,7 @@ use Piwik\Plugins\API\API as ApiModuleApi; use Piwik\Plugins\CustomDimensions\API as CustomDimensionsApi; use Piwik\Plugins\Goals\API as GoalsApi; -use Piwik\Plugins\McpServer\McpTools\ApiCall; +use Piwik\Plugins\McpServer\McpTools\ApiCallRead; use Piwik\Plugins\McpServer\McpTools\ApiGet; use Piwik\Plugins\McpServer\McpTools\DimensionGet; use Piwik\Plugins\McpServer\McpTools\DimensionList; @@ -293,7 +293,7 @@ public function testApiCallSuccessShapeInReadMode(): void $content = McpTestHelper::callToolAndAssertSuccess( $server, $sessionId, - ApiCall::TOOL_NAME, + ApiCallRead::TOOL_NAME, ['method' => 'API.getMatomoVersion'], __METHOD__, ); diff --git a/tests/Integration/McpToolsContractTest.php b/tests/Integration/McpToolsContractTest.php index e4442e9..08451cf 100644 --- a/tests/Integration/McpToolsContractTest.php +++ b/tests/Integration/McpToolsContractTest.php @@ -12,7 +12,11 @@ namespace Piwik\Plugins\McpServer\tests\Integration; use Matomo\Dependencies\McpServer\Mcp\Schema\Tool; -use Piwik\Plugins\McpServer\McpTools\ApiCall; +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\tests\Framework\McpTestHelper; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -164,7 +168,11 @@ public function testRawApiListToolIsHiddenWhenRawAccessModeIsNone(): void McpTestHelper::setRawApiAccessMode('none'); $toolsByName = $this->listToolsByNameForCurrentConfig(); - self::assertArrayNotHasKey(ApiCall::TOOL_NAME, $toolsByName); + self::assertArrayNotHasKey(ApiCallRead::TOOL_NAME, $toolsByName); + self::assertArrayNotHasKey(ApiCallCreate::TOOL_NAME, $toolsByName); + self::assertArrayNotHasKey(ApiCallUpdate::TOOL_NAME, $toolsByName); + self::assertArrayNotHasKey(ApiCallDelete::TOOL_NAME, $toolsByName); + self::assertArrayNotHasKey(ApiCallFull::TOOL_NAME, $toolsByName); self::assertArrayNotHasKey('matomo_api_get', $toolsByName); self::assertArrayNotHasKey('matomo_api_list', $toolsByName); } @@ -190,12 +198,13 @@ public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessM self::assertTrue($tool->annotations->idempotentHint); self::assertFalse($tool->annotations->openWorldHint); - self::assertArrayHasKey(ApiCall::TOOL_NAME, $toolsByName); - $callTool = $toolsByName[ApiCall::TOOL_NAME]; + self::assertArrayHasKey(ApiCallRead::TOOL_NAME, $toolsByName); + self::assertArrayNotHasKey(ApiCallFull::TOOL_NAME, $toolsByName); + $callTool = $toolsByName[ApiCallRead::TOOL_NAME]; self::assertNotNull($callTool->annotations); - self::assertFalse($callTool->annotations->readOnlyHint); + self::assertTrue($callTool->annotations->readOnlyHint); self::assertFalse($callTool->annotations->destructiveHint); - self::assertFalse($callTool->annotations->idempotentHint); + self::assertTrue($callTool->annotations->idempotentHint); self::assertFalse($callTool->annotations->openWorldHint); } @@ -206,11 +215,32 @@ public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessM self::assertArrayHasKey('matomo_api_get', $toolsByName); self::assertArrayHasKey('matomo_api_list', $toolsByName); - self::assertArrayHasKey(ApiCall::TOOL_NAME, $toolsByName); + self::assertArrayHasKey(ApiCallCreate::TOOL_NAME, $toolsByName); + self::assertArrayNotHasKey(ApiCallFull::TOOL_NAME, $toolsByName); - $callTool = $toolsByName[ApiCall::TOOL_NAME]; + $callTool = $toolsByName[ApiCallCreate::TOOL_NAME]; self::assertNotNull($callTool->annotations); self::assertFalse($callTool->annotations->readOnlyHint); + self::assertFalse($callTool->annotations->destructiveHint); + self::assertFalse($callTool->annotations->idempotentHint); + self::assertFalse($callTool->annotations->openWorldHint); + } + + public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessModeIsUpdate(): void + { + McpTestHelper::setRawApiAccessMode('update'); + $toolsByName = $this->listToolsByNameForCurrentConfig(); + + self::assertArrayHasKey('matomo_api_get', $toolsByName); + self::assertArrayHasKey('matomo_api_list', $toolsByName); + self::assertArrayHasKey(ApiCallUpdate::TOOL_NAME, $toolsByName); + self::assertArrayNotHasKey(ApiCallFull::TOOL_NAME, $toolsByName); + + $callTool = $toolsByName[ApiCallUpdate::TOOL_NAME]; + self::assertNotNull($callTool->annotations); + self::assertFalse($callTool->annotations->readOnlyHint); + self::assertFalse($callTool->annotations->destructiveHint); + self::assertFalse($callTool->annotations->idempotentHint); self::assertFalse($callTool->annotations->openWorldHint); } @@ -231,10 +261,13 @@ public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessM self::assertTrue($tool->annotations->readOnlyHint); self::assertFalse($tool->annotations->openWorldHint); - self::assertArrayHasKey(ApiCall::TOOL_NAME, $toolsByName); - $callTool = $toolsByName[ApiCall::TOOL_NAME]; + self::assertArrayHasKey(ApiCallDelete::TOOL_NAME, $toolsByName); + self::assertArrayNotHasKey(ApiCallFull::TOOL_NAME, $toolsByName); + $callTool = $toolsByName[ApiCallDelete::TOOL_NAME]; self::assertNotNull($callTool->annotations); self::assertFalse($callTool->annotations->readOnlyHint); + self::assertTrue($callTool->annotations->destructiveHint); + self::assertFalse($callTool->annotations->idempotentHint); self::assertFalse($callTool->annotations->openWorldHint); } @@ -259,8 +292,12 @@ public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessM self::assertTrue($tool->annotations->idempotentHint); self::assertFalse($tool->annotations->openWorldHint); - self::assertArrayHasKey(ApiCall::TOOL_NAME, $toolsByName); - $callTool = $toolsByName[ApiCall::TOOL_NAME]; + self::assertArrayHasKey(ApiCallRead::TOOL_NAME, $toolsByName); + self::assertArrayHasKey(ApiCallCreate::TOOL_NAME, $toolsByName); + self::assertArrayHasKey(ApiCallUpdate::TOOL_NAME, $toolsByName); + self::assertArrayHasKey(ApiCallDelete::TOOL_NAME, $toolsByName); + self::assertArrayHasKey(ApiCallFull::TOOL_NAME, $toolsByName); + $callTool = $toolsByName[ApiCallFull::TOOL_NAME]; self::assertNotNull($callTool->annotations); self::assertFalse($callTool->annotations->readOnlyHint); self::assertTrue($callTool->annotations->destructiveHint); diff --git a/tests/Unit/McpServerFactoryTest.php b/tests/Unit/McpServerFactoryTest.php index 3b1853f..3479b22 100644 --- a/tests/Unit/McpServerFactoryTest.php +++ b/tests/Unit/McpServerFactoryTest.php @@ -19,6 +19,11 @@ use Piwik\Log\LoggerInterface; use Piwik\Plugin\Manager; use Piwik\Plugins\McpServer\McpServerFactory; +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\Support\Logging\ToolCallParameterFormatter; use Piwik\Plugins\McpServer\SystemSettings; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; @@ -414,27 +419,32 @@ public function testInvalidToolCallLogLevelFallsBackToDebug(): void public function testRawApiListToolIsVisibleWhenRawAccessModeAllowsDirectApiAccess(): void { $toolsWhenRead = $this->listToolNamesForCurrentConfig('read'); - self::assertContains('matomo_api_call', $toolsWhenRead); + self::assertContains(ApiCallRead::TOOL_NAME, $toolsWhenRead); self::assertContains('matomo_api_get', $toolsWhenRead); self::assertContains('matomo_api_list', $toolsWhenRead); + self::assertNotContains(ApiCallFull::TOOL_NAME, $toolsWhenRead); $toolsWhenCreate = $this->listToolNamesForCurrentConfig('create'); - self::assertContains('matomo_api_call', $toolsWhenCreate); + self::assertContains(ApiCallCreate::TOOL_NAME, $toolsWhenCreate); self::assertContains('matomo_api_get', $toolsWhenCreate); self::assertContains('matomo_api_list', $toolsWhenCreate); $toolsWhenUpdate = $this->listToolNamesForCurrentConfig('update'); - self::assertContains('matomo_api_call', $toolsWhenUpdate); + self::assertContains(ApiCallUpdate::TOOL_NAME, $toolsWhenUpdate); self::assertContains('matomo_api_get', $toolsWhenUpdate); self::assertContains('matomo_api_list', $toolsWhenUpdate); $toolsWhenDelete = $this->listToolNamesForCurrentConfig('delete'); - self::assertContains('matomo_api_call', $toolsWhenDelete); + self::assertContains(ApiCallDelete::TOOL_NAME, $toolsWhenDelete); self::assertContains('matomo_api_get', $toolsWhenDelete); self::assertContains('matomo_api_list', $toolsWhenDelete); $toolsWhenFull = $this->listToolNamesForCurrentConfig('full'); - self::assertContains('matomo_api_call', $toolsWhenFull); + self::assertContains(ApiCallRead::TOOL_NAME, $toolsWhenFull); + self::assertContains(ApiCallCreate::TOOL_NAME, $toolsWhenFull); + self::assertContains(ApiCallUpdate::TOOL_NAME, $toolsWhenFull); + self::assertContains(ApiCallDelete::TOOL_NAME, $toolsWhenFull); + self::assertContains(ApiCallFull::TOOL_NAME, $toolsWhenFull); self::assertContains('matomo_api_get', $toolsWhenFull); self::assertContains('matomo_api_list', $toolsWhenFull); } @@ -462,22 +472,37 @@ public function testRawApiGetToolHasFullAnnotationsWhenVisible(): void self::assertFalse($toolWhenFull->annotations->openWorldHint); } - public function testRawApiCallToolHasFullAnnotationsWhenVisible(): void + public function testRawApiCallToolsHaveExpectedAnnotationsWhenVisible(): void { $toolsWhenRead = $this->listToolsByNameForCurrentConfig('read'); - self::assertArrayHasKey('matomo_api_call', $toolsWhenRead); - $toolWhenRead = $toolsWhenRead['matomo_api_call']; + self::assertArrayHasKey(ApiCallRead::TOOL_NAME, $toolsWhenRead); + $toolWhenRead = $toolsWhenRead[ApiCallRead::TOOL_NAME]; self::assertNotNull($toolWhenRead->annotations); - self::assertFalse($toolWhenRead->annotations->readOnlyHint); + self::assertTrue($toolWhenRead->annotations->readOnlyHint); self::assertFalse($toolWhenRead->annotations->destructiveHint); - self::assertFalse($toolWhenRead->annotations->idempotentHint); + self::assertTrue($toolWhenRead->annotations->idempotentHint); self::assertFalse($toolWhenRead->annotations->openWorldHint); $toolsWhenFull = $this->listToolsByNameForCurrentConfig('full'); - self::assertArrayHasKey('matomo_api_call', $toolsWhenFull); - $toolWhenFull = $toolsWhenFull['matomo_api_call']; + self::assertArrayHasKey(ApiCallCreate::TOOL_NAME, $toolsWhenFull); + self::assertNotNull($toolsWhenFull[ApiCallCreate::TOOL_NAME]->annotations); + self::assertFalse($toolsWhenFull[ApiCallCreate::TOOL_NAME]->annotations->readOnlyHint); + self::assertFalse($toolsWhenFull[ApiCallCreate::TOOL_NAME]->annotations->destructiveHint); + + self::assertArrayHasKey(ApiCallUpdate::TOOL_NAME, $toolsWhenFull); + self::assertNotNull($toolsWhenFull[ApiCallUpdate::TOOL_NAME]->annotations); + self::assertFalse($toolsWhenFull[ApiCallUpdate::TOOL_NAME]->annotations->readOnlyHint); + self::assertFalse($toolsWhenFull[ApiCallUpdate::TOOL_NAME]->annotations->destructiveHint); + + self::assertArrayHasKey(ApiCallDelete::TOOL_NAME, $toolsWhenFull); + self::assertNotNull($toolsWhenFull[ApiCallDelete::TOOL_NAME]->annotations); + self::assertFalse($toolsWhenFull[ApiCallDelete::TOOL_NAME]->annotations->readOnlyHint); + self::assertTrue($toolsWhenFull[ApiCallDelete::TOOL_NAME]->annotations->destructiveHint); + + self::assertArrayHasKey(ApiCallFull::TOOL_NAME, $toolsWhenFull); + $toolWhenFull = $toolsWhenFull[ApiCallFull::TOOL_NAME]; self::assertNotNull($toolWhenFull->annotations); self::assertFalse($toolWhenFull->annotations->readOnlyHint); self::assertTrue($toolWhenFull->annotations->destructiveHint); diff --git a/tests/Unit/McpTools/ApiCallTest.php b/tests/Unit/McpTools/ApiCallTest.php index f727e86..66a79fb 100644 --- a/tests/Unit/McpTools/ApiCallTest.php +++ b/tests/Unit/McpTools/ApiCallTest.php @@ -11,11 +11,14 @@ namespace Piwik\Plugins\McpServer\tests\Unit\McpTools; +use Matomo\Dependencies\McpServer\Mcp\Exception\ToolCallException; use PHPUnit\Framework\TestCase; use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiCallQueryServiceInterface; +use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiMethodSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiCallRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; -use Piwik\Plugins\McpServer\McpTools\ApiCall; +use Piwik\Plugins\McpServer\McpTools\ApiCallFull; +use Piwik\Plugins\McpServer\McpTools\ApiCallRead; use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier; use Piwik\Plugins\McpServer\SystemSettings; use stdClass; @@ -31,41 +34,48 @@ public function testCallUsesMethodSelector(): void $captured = new stdClass(); $captured->values = []; - $tool = new ApiCall( + $record = new ApiMethodSummaryRecord( + 'API', + 'getMatomoVersion', + 'API.getMatomoVersion', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ); + + $tool = new ApiCallRead( new class ($captured) implements ApiCallQueryServiceInterface { public function __construct(private stdClass $captured) { } public function callApi( - string $accessMode, - ?string $method = null, - ?string $module = null, - ?string $action = null, + ApiMethodSummaryRecord $resolvedMethod, ?array $parameters = null, ): ApiCallRecord { $this->captured->values = [ - 'accessMode' => $accessMode, - 'method' => $method, - 'module' => $module, - 'action' => $action, + 'resolvedMethod' => $resolvedMethod, 'parameters' => $parameters, ]; - return new ApiCallRecord( - '6.0.0', - new ApiMethodSummaryRecord( - 'API', - 'getMatomoVersion', - 'API.getMatomoVersion', - [], - ApiMethodOperationClassifier::CATEGORY_READ, - ApiMethodOperationClassifier::CONFIDENCE_HIGH, - 'action-prefix:get', - ), + return new ApiCallRecord('6.0.0', $this->createRecord()); + } + + private function createRecord(): ApiMethodSummaryRecord + { + return new ApiMethodSummaryRecord( + 'API', + 'getMatomoVersion', + 'API.getMatomoVersion', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', ); } }, + $this->createMethodSummaryQueryServiceStub($record), $this->createSystemSettingsStub('read'), ); @@ -83,10 +93,8 @@ public function callApi( ], $actual); /** @var array $capturedValues */ $capturedValues = $captured->values; - self::assertSame('read', $capturedValues['accessMode']); - self::assertSame(' API.getMatomoVersion ', $capturedValues['method']); - self::assertNull($capturedValues['module']); - self::assertNull($capturedValues['action']); + self::assertInstanceOf(ApiMethodSummaryRecord::class, $capturedValues['resolvedMethod']); + self::assertSame('API.getMatomoVersion', $capturedValues['resolvedMethod']->method); self::assertNull($capturedValues['parameters']); } @@ -95,24 +103,28 @@ public function testCallUsesSplitSelectorAndParameters(): void $captured = new stdClass(); $captured->values = []; - $tool = new ApiCall( + $record = new ApiMethodSummaryRecord( + 'UsersManager', + 'addUser', + 'UsersManager.addUser', + [], + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:add', + ); + + $tool = new ApiCallFull( new class ($captured) implements ApiCallQueryServiceInterface { public function __construct(private stdClass $captured) { } public function callApi( - string $accessMode, - ?string $method = null, - ?string $module = null, - ?string $action = null, + ApiMethodSummaryRecord $resolvedMethod, ?array $parameters = null, ): ApiCallRecord { $this->captured->values = [ - 'accessMode' => $accessMode, - 'method' => $method, - 'module' => $module, - 'action' => $action, + 'resolvedMethod' => $resolvedMethod, 'parameters' => $parameters, ]; @@ -130,6 +142,7 @@ public function callApi( ); } }, + $this->createMethodSummaryQueryServiceStub($record), $this->createSystemSettingsStub('full'), ); @@ -142,13 +155,43 @@ public function callApi( self::assertSame(['success' => true], $actual['result']); /** @var array $capturedValues */ $capturedValues = $captured->values; - self::assertSame('full', $capturedValues['accessMode']); - self::assertNull($capturedValues['method']); - self::assertSame(' UsersManager ', $capturedValues['module']); - self::assertSame(' addUser ', $capturedValues['action']); + self::assertInstanceOf(ApiMethodSummaryRecord::class, $capturedValues['resolvedMethod']); + self::assertSame('UsersManager.addUser', $capturedValues['resolvedMethod']->method); self::assertSame(['userLogin' => 'alice'], $capturedValues['parameters']); } + public function testScopedToolRejectsMethodOutsideExpectedOperationCategory(): void + { + $tool = new ApiCallRead( + $this->createMock(ApiCallQueryServiceInterface::class), + $this->createMethodSummaryQueryServiceStub(new ApiMethodSummaryRecord( + 'UsersManager', + 'addUser', + 'UsersManager.addUser', + [], + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:add', + )), + $this->createSystemSettingsStub('full'), + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('API method not found or unavailable.'); + + $tool->call(method: 'UsersManager.addUser'); + } + + private function createMethodSummaryQueryServiceStub( + ApiMethodSummaryRecord $record, + ): ApiMethodSummaryQueryServiceInterface { + $service = $this->createMock(ApiMethodSummaryQueryServiceInterface::class); + $service->method('getApiMethodSummaryBySelector') + ->willReturn($record); + + return $service; + } + private function createSystemSettingsStub(string $rawApiAccessMode): SystemSettings { $settings = $this->createMock(SystemSettings::class); diff --git a/tests/Unit/Services/ApiCallQueryServiceTest.php b/tests/Unit/Services/ApiCallQueryServiceTest.php index 6969500..f6ef6ff 100644 --- a/tests/Unit/Services/ApiCallQueryServiceTest.php +++ b/tests/Unit/Services/ApiCallQueryServiceTest.php @@ -16,14 +16,11 @@ use Piwik\DataTable; use Piwik\DataTable\Map; use Piwik\DataTable\Row; -use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiMethodSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\Api\CoreApiCallGatewayInterface; -use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryQueryRecord; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord; use Piwik\Plugins\McpServer\Services\Api\ApiCallQueryService; use Piwik\Plugins\McpServer\Support\Errors\AccessDeniedLikeException; use Piwik\Plugins\McpServer\Support\Errors\CoreApiRequestException; -use stdClass; /** * @group McpServer @@ -31,38 +28,11 @@ */ class ApiCallQueryServiceTest extends TestCase { - public function testCallApiResolvesSelectorAndReturnsEnvelope(): void + public function testCallApiUsesResolvedMethodAndReturnsEnvelope(): void { - $captured = new stdClass(); - $captured->values = []; + $resolvedMethod = new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []); $service = new ApiCallQueryService( - new class ($captured) implements ApiMethodSummaryQueryServiceInterface { - public function __construct(private stdClass $captured) - { - } - - public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array - { - return []; - } - - public function getApiMethodSummaryBySelector( - string $accessMode, - ?string $method = null, - ?string $module = null, - ?string $action = null, - ): ApiMethodSummaryRecord { - $this->captured->values = [ - 'accessMode' => $accessMode, - 'method' => $method, - 'module' => $module, - 'action' => $action, - ]; - - return new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []); - } - }, new class () implements CoreApiCallGatewayInterface { public function call(string $method, array $parameters): mixed { @@ -74,24 +44,16 @@ public function call(string $method, array $parameters): mixed }, ); - $record = $service->callApi('read', 'API.getMatomoVersion'); + $record = $service->callApi($resolvedMethod); self::assertSame('6.0.0', $record->result); self::assertSame('API.getMatomoVersion', $record->resolvedMethod->method); - /** @var array $capturedValues */ - $capturedValues = $captured->values; - self::assertSame([ - 'accessMode' => 'read', - 'method' => 'API.getMatomoVersion', - 'module' => null, - 'action' => null, - ], $capturedValues); } public function testCallApiPassesParametersAndNormalizesObjects(): void { + $resolvedMethod = new ApiMethodSummaryRecord('API', 'getSettings', 'API.getSettings', []); $service = new ApiCallQueryService( - $this->createQueryServiceStub(new ApiMethodSummaryRecord('API', 'getSettings', 'API.getSettings', [])), new class () implements CoreApiCallGatewayInterface { public function call(string $method, array $parameters): mixed { @@ -102,13 +64,14 @@ public function call(string $method, array $parameters): mixed }, ); - $record = $service->callApi('read', 'API.getSettings', parameters: ['idSite' => 3]); + $record = $service->callApi($resolvedMethod, parameters: ['idSite' => 3]); self::assertSame(['site' => ['id' => 3, 'name' => 'Demo']], $record->result); } public function testCallApiNormalizesDataTableResultsViaJsonRenderer(): void { + $resolvedMethod = new ApiMethodSummaryRecord('Actions', 'getPageUrls', 'Actions.getPageUrls', []); $table = new DataTable(); $table->addRow(new Row([ Row::COLUMNS => [ @@ -118,9 +81,6 @@ public function testCallApiNormalizesDataTableResultsViaJsonRenderer(): void ])); $service = new ApiCallQueryService( - $this->createQueryServiceStub( - new ApiMethodSummaryRecord('Actions', 'getPageUrls', 'Actions.getPageUrls', []), - ), new class ($table) implements CoreApiCallGatewayInterface { public function __construct(private DataTable $table) { @@ -133,7 +93,7 @@ public function call(string $method, array $parameters): mixed }, ); - $record = $service->callApi('read', 'Actions.getPageUrls'); + $record = $service->callApi($resolvedMethod); self::assertSame([ [ @@ -145,6 +105,7 @@ public function call(string $method, array $parameters): mixed public function testCallApiNormalizesNestedDataTableMapResultsViaJsonRenderer(): void { + $resolvedMethod = new ApiMethodSummaryRecord('Live', 'getLastVisitDetails', 'Live.getLastVisitDetails', []); $first = new DataTable(); $first->addRow(new Row([ Row::COLUMNS => [ @@ -166,9 +127,6 @@ public function testCallApiNormalizesNestedDataTableMapResultsViaJsonRenderer(): $map->addTable($second, '2024-01-02'); $service = new ApiCallQueryService( - $this->createQueryServiceStub( - new ApiMethodSummaryRecord('Live', 'getLastVisitDetails', 'Live.getLastVisitDetails', []), - ), new class ($map) implements CoreApiCallGatewayInterface { public function __construct(private Map $map) { @@ -181,7 +139,7 @@ public function call(string $method, array $parameters): mixed }, ); - $record = $service->callApi('read', 'Live.getLastVisitDetails'); + $record = $service->callApi($resolvedMethod); self::assertSame([ 'report' => [ @@ -203,10 +161,8 @@ public function call(string $method, array $parameters): mixed public function testCallApiRejectsReservedParameterKeys(): void { + $resolvedMethod = new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []); $service = new ApiCallQueryService( - $this->createQueryServiceStub( - new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), - ), new class () implements CoreApiCallGatewayInterface { public function call(string $method, array $parameters): mixed { @@ -218,15 +174,13 @@ public function call(string $method, array $parameters): mixed $this->expectException(ToolCallException::class); $this->expectExceptionMessage("Unsupported parameters key 'format'."); - $service->callApi('read', 'API.getMatomoVersion', parameters: ['format' => 'json']); + $service->callApi($resolvedMethod, parameters: ['format' => 'json']); } public function testCallApiMapsAccessDeniedFailures(): void { + $resolvedMethod = new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []); $service = new ApiCallQueryService( - $this->createQueryServiceStub( - new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), - ), new class () implements CoreApiCallGatewayInterface { public function call(string $method, array $parameters): mixed { @@ -238,15 +192,13 @@ public function call(string $method, array $parameters): mixed $this->expectException(ToolCallException::class); $this->expectExceptionMessage('No access to API method.'); - $service->callApi('read', 'API.getMatomoVersion'); + $service->callApi($resolvedMethod); } public function testCallApiMapsUpstreamFailures(): void { + $resolvedMethod = new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []); $service = new ApiCallQueryService( - $this->createQueryServiceStub( - new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), - ), new class () implements CoreApiCallGatewayInterface { public function call(string $method, array $parameters): mixed { @@ -258,15 +210,13 @@ public function call(string $method, array $parameters): mixed $this->expectException(ToolCallException::class); $this->expectExceptionMessage('Matomo API request failed.'); - $service->callApi('full', 'UsersManager.addUser'); + $service->callApi($resolvedMethod); } public function testCallApiSurfacesSanitizedValidationFailureDetail(): void { + $resolvedMethod = new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []); $service = new ApiCallQueryService( - $this->createQueryServiceStub( - new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), - ), new class () implements CoreApiCallGatewayInterface { public function call(string $method, array $parameters): mixed { @@ -282,15 +232,13 @@ public function call(string $method, array $parameters): mixed $this->expectException(ToolCallException::class); $this->expectExceptionMessage("Matomo API request failed: Parameter 'userLogin' missing or invalid."); - $service->callApi('full', 'UsersManager.addUser'); + $service->callApi($resolvedMethod); } public function testCallApiKeepsGenericFailureForUnsafeUpstreamDetail(): void { + $resolvedMethod = new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []); $service = new ApiCallQueryService( - $this->createQueryServiceStub( - new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), - ), new class () implements CoreApiCallGatewayInterface { public function call(string $method, array $parameters): mixed { @@ -306,15 +254,13 @@ public function call(string $method, array $parameters): mixed $this->expectException(ToolCallException::class); $this->expectExceptionMessage('Matomo API request failed.'); - $service->callApi('full', 'UsersManager.addUser'); + $service->callApi($resolvedMethod); } public function testCallApiKeepsGenericFailureWhenNoSafeDetailExists(): void { + $resolvedMethod = new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []); $service = new ApiCallQueryService( - $this->createQueryServiceStub( - new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []), - ), new class () implements CoreApiCallGatewayInterface { public function call(string $method, array $parameters): mixed { @@ -326,19 +272,17 @@ public function call(string $method, array $parameters): mixed $this->expectException(ToolCallException::class); $this->expectExceptionMessage('Matomo API request failed.'); - $service->callApi('full', 'UsersManager.addUser'); + $service->callApi($resolvedMethod); } public function testCallApiRejectsInvalidResponse(): void { $resource = fopen('php://memory', 'rb'); self::assertIsResource($resource); + $resolvedMethod = new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []); try { $service = new ApiCallQueryService( - $this->createQueryServiceStub( - new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []), - ), new class ($resource) implements CoreApiCallGatewayInterface { private mixed $resource; @@ -357,32 +301,9 @@ public function call(string $method, array $parameters): mixed $this->expectException(ToolCallException::class); $this->expectExceptionMessage('API response is invalid.'); - $service->callApi('read', 'API.getMatomoVersion'); + $service->callApi($resolvedMethod); } finally { fclose($resource); } } - - private function createQueryServiceStub(ApiMethodSummaryRecord $record): ApiMethodSummaryQueryServiceInterface - { - return new class ($record) implements ApiMethodSummaryQueryServiceInterface { - public function __construct(private ApiMethodSummaryRecord $record) - { - } - - public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array - { - return []; - } - - public function getApiMethodSummaryBySelector( - string $accessMode, - ?string $method = null, - ?string $module = null, - ?string $action = null, - ): ApiMethodSummaryRecord { - return $this->record; - } - }; - } } From 38e04efecb95ca711b2f4e334f648f19061841e6 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Wed, 8 Apr 2026 22:59:09 +0200 Subject: [PATCH 11/15] Update README --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 20795ce..760c236 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ McpServer is an early preview plugin that adds a secure Model Context Protocol (MCP) endpoint to Matomo so AI assistants and MCP-compatible clients can work with analytics context directly from your Matomo instance. -It provides read-oriented tools for sites, reports, processed report data, goals, segments, and dimensions using the same Matomo authentication and access rules you already use in Matomo. +It provides analytics tools for sites, reports, processed report data, goals, segments, and dimensions using the same Matomo authentication and access rules you already use in Matomo. Administrators can also optionally enable raw Matomo API discovery and direct API execution tools for advanced MCP workflows. ### Setup @@ -18,13 +18,16 @@ For the recommended end-user setup flow, use the in-product connect guide at **A ### Security And Access Model - MCP access is disabled by default. +- Raw Matomo API discovery and execution tools are separately disabled by default and must be enabled by an administrator. - The plugin uses Matomo authentication. - Data access is limited to the same sites and reports the Matomo user can already access. +- When raw API access is enabled, MCP clients can access the same Matomo API surface available to the authenticated user, including state-changing methods if an administrator has allowed them. - If features such as the Visitor Log are available to that user, MCP clients may access the same underlying data scope. +- Review privacy, security, and compliance requirements before enabling raw API access. ### Additional Documentation -The FAQ includes additional technical documentation for endpoint details, configuration, MCP enablement behavior, supported capabilities, and troubleshooting. +The FAQ includes additional technical documentation for endpoint details, configuration, MCP enablement behavior, raw API access guidance, supported capabilities, and troubleshooting. ## Support From b242fbca3f241da5c023edc96e1b346d7d25730e Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Wed, 8 Apr 2026 23:15:52 +0200 Subject: [PATCH 12/15] Handle empty API tool parameters more gracefully --- McpTools/AbstractApiCall.php | 5 ++ Schemas/Api/ApiCallToolInputSchema.php | 15 ++++- tests/Integration/McpTools/ApiCallTest.php | 71 ++++++++++++++++++++++ tests/Unit/McpTools/ApiCallTest.php | 70 +++++++++++++++++++++ 4 files changed, 158 insertions(+), 3 deletions(-) diff --git a/McpTools/AbstractApiCall.php b/McpTools/AbstractApiCall.php index 51cf564..fa7fea9 100644 --- a/McpTools/AbstractApiCall.php +++ b/McpTools/AbstractApiCall.php @@ -15,6 +15,7 @@ use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiCallQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\Api\ApiMethodSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiCallRecord; +use Piwik\Plugins\McpServer\Support\Normalization\ToolDataNormalizer; use Piwik\Plugins\McpServer\SystemSettings; /** @@ -41,6 +42,10 @@ public function call( ?string $action = null, ?array $parameters = null, ): array { + $parameters = $parameters === null + ? null + : ToolDataNormalizer::requireStringKeyedArrayOrEmptyList($parameters, 'parameters'); + $accessMode = $this->systemSettings->getRawApiAccessMode(); $resolvedMethod = $this->apiMethodSummaryQueryService->getApiMethodSummaryBySelector( $accessMode, diff --git a/Schemas/Api/ApiCallToolInputSchema.php b/Schemas/Api/ApiCallToolInputSchema.php index 9f0d0b5..e216989 100644 --- a/Schemas/Api/ApiCallToolInputSchema.php +++ b/Schemas/Api/ApiCallToolInputSchema.php @@ -32,9 +32,18 @@ final class ApiCallToolInputSchema 'description' => 'Exact Matomo API action name.', ], 'parameters' => [ - 'type' => 'object', - 'additionalProperties' => true, - 'description' => 'Optional Matomo API parameters for the selected method.', + 'oneOf' => [ + [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'Optional Matomo API parameters for the selected method.', + ], + [ + 'type' => 'array', + 'maxItems' => 0, + 'description' => 'Empty array is accepted and normalized as no parameters.', + ], + ], ], ], 'not' => [ diff --git a/tests/Integration/McpTools/ApiCallTest.php b/tests/Integration/McpTools/ApiCallTest.php index 7450e55..c53991f 100644 --- a/tests/Integration/McpTools/ApiCallTest.php +++ b/tests/Integration/McpTools/ApiCallTest.php @@ -120,6 +120,54 @@ public function testReadModeCallsKnownReadMethodByModuleAndActionSelector(): voi self::assertIsString($content['result']); } + public function testReadModeAcceptsEmptyParametersList(): void + { + McpTestHelper::setRawApiAccessMode('read'); + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCallRead::TOOL_NAME, + ['method' => 'API.getMatomoVersion', 'parameters' => []], + __METHOD__, + ); + + $resolvedMethod = $content['resolvedMethod'] ?? null; + self::assertIsArray($resolvedMethod); + self::assertSame('API.getMatomoVersion', $resolvedMethod['method'] ?? null); + self::assertIsString($content['result'] ?? null); + } + + public function testReadModeAcceptsEmptyParametersObject(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = json_encode([ + 'jsonrpc' => '2.0', + 'id' => __METHOD__, + 'method' => 'tools/call', + 'params' => [ + 'name' => ApiCallRead::TOOL_NAME, + 'arguments' => [ + 'method' => 'API.getMatomoVersion', + 'parameters' => new \stdClass(), + ], + ], + ], JSON_THROW_ON_ERROR); + + $response = McpTestHelper::postJson($server, $payload, ['Mcp-Session-Id' => $sessionId]); + $message = McpTestHelper::decodeResponse($response); + $result = McpTestHelper::parseCallTool($message); + $content = McpTestHelper::assertToolSuccess($result); + $resolvedMethod = $content['resolvedMethod'] ?? null; + self::assertIsArray($resolvedMethod); + self::assertSame('API.getMatomoVersion', $resolvedMethod['method'] ?? null); + self::assertIsString($content['result'] ?? null); + } + public function testReadModeNormalizesDataTableResponse(): void { McpTestHelper::setRawApiAccessMode('read'); @@ -393,6 +441,29 @@ public function testRejectsReservedParameterKeys(): void ); } + public function testRejectsNonEmptyParametersListAtSchemaLevel(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $message = McpTestHelper::callToolExpectInvalidParams( + $server, + $sessionId, + ApiCallRead::TOOL_NAME, + [ + 'method' => 'API.getMatomoVersion', + 'parameters' => ['flat'], + ], + __METHOD__, + ); + + self::assertStringContainsString( + "Invalid parameters for tool '" . ApiCallRead::TOOL_NAME . "':", + $message->message, + ); + } + public function testRejectsMissingSelectorAtSchemaLevel(): void { McpTestHelper::setRawApiAccessMode('full'); diff --git a/tests/Unit/McpTools/ApiCallTest.php b/tests/Unit/McpTools/ApiCallTest.php index 66a79fb..06aa59f 100644 --- a/tests/Unit/McpTools/ApiCallTest.php +++ b/tests/Unit/McpTools/ApiCallTest.php @@ -160,6 +160,76 @@ public function callApi( self::assertSame(['userLogin' => 'alice'], $capturedValues['parameters']); } + public function testCallNormalizesEmptyParameterList(): void + { + $captured = new stdClass(); + $captured->values = []; + + $record = new ApiMethodSummaryRecord( + 'API', + 'getMatomoVersion', + 'API.getMatomoVersion', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ); + + $tool = new ApiCallRead( + new class ($captured) implements ApiCallQueryServiceInterface { + public function __construct(private stdClass $captured) + { + } + + public function callApi( + ApiMethodSummaryRecord $resolvedMethod, + ?array $parameters = null, + ): ApiCallRecord { + $this->captured->values = [ + 'resolvedMethod' => $resolvedMethod, + 'parameters' => $parameters, + ]; + + return new ApiCallRecord('6.0.0', $resolvedMethod); + } + }, + $this->createMethodSummaryQueryServiceStub($record), + $this->createSystemSettingsStub('read'), + ); + + $actual = $tool->call(method: 'API.getMatomoVersion', parameters: []); + + self::assertSame('6.0.0', $actual['result']); + /** @var array $capturedValues */ + $capturedValues = $captured->values; + self::assertSame([], $capturedValues['parameters']); + } + + public function testCallRejectsNonEmptyParameterList(): void + { + $record = new ApiMethodSummaryRecord( + 'API', + 'getMatomoVersion', + 'API.getMatomoVersion', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ); + + $tool = new ApiCallRead( + $this->createMock(ApiCallQueryServiceInterface::class), + $this->createMethodSummaryQueryServiceStub($record), + $this->createSystemSettingsStub('read'), + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('parameters is invalid.'); + + /** @phpstan-ignore-next-line intentional invalid direct invocation coverage */ + $tool->call(method: 'API.getMatomoVersion', parameters: ['flat']); + } + public function testScopedToolRejectsMethodOutsideExpectedOperationCategory(): void { $tool = new ApiCallRead( From b0b7acff00d63b650ba65b37f879457ec85818df Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Thu, 9 Apr 2026 19:50:42 +0200 Subject: [PATCH 13/15] Add auth token privilege cap setting --- API.php | 27 +++ README.md | 2 + Support/Access/McpAccessLevel.php | 111 +++++++++++ Support/Api/McpEndpointSpec.php | 2 + SystemSettings.php | 31 ++++ docs/faq.md | 9 +- lang/en.json | 8 + tests/Framework/McpAuthTestHelper.php | 174 ++++++++++++------ tests/Framework/McpTestHelper.php | 21 +++ .../McpApiEndpointBoundaryTest.php | 150 +++++++++++++++ tests/Integration/McpServerTest.php | 12 ++ tests/Integration/SystemSettingsTest.php | 31 ++++ tests/UI/McpServer_spec.js | 25 ++- tests/Unit/APITest.php | 78 +++++++- .../Support/Access/McpAccessLevelTest.php | 44 +++++ 15 files changed, 662 insertions(+), 63 deletions(-) create mode 100644 Support/Access/McpAccessLevel.php create mode 100644 tests/Unit/Support/Access/McpAccessLevelTest.php diff --git a/API.php b/API.php index ddd4b9a..98de732 100644 --- a/API.php +++ b/API.php @@ -20,6 +20,7 @@ use Piwik\Http\BadRequestException; use Piwik\NoAccessException; use Piwik\Piwik; +use Piwik\Plugins\McpServer\Support\Access\McpAccessLevel; use Piwik\Plugins\McpServer\Support\Api\JsonRpcErrorResponseFactory; use Piwik\Plugins\McpServer\Support\Api\JsonRpcRequestIdExtractor; use Piwik\Plugins\McpServer\Support\Api\McpEndpointGuard; @@ -96,6 +97,10 @@ public function mcp(): ResponseInterface return $this->createDisabledResponse($requestMetadata['topLevelRequestId']); } + if (!$this->isCurrentUserPrivilegeLevelAllowed()) { + return $this->createPrivilegeTooHighResponse($requestMetadata['topLevelRequestId']); + } + try { $server = $this->factory->createServer(); $transport = new StreamableHttpTransport($request); @@ -137,6 +142,14 @@ protected function isMcpEnabled(): bool return $this->systemSettings->isMcpEnabled(); } + protected function isCurrentUserPrivilegeLevelAllowed(): bool + { + return !McpAccessLevel::exceedsMaximumAllowed( + McpAccessLevel::resolveCurrentUserLevel(), + $this->systemSettings->getMaximumAllowedMcpAccessLevel(), + ); + } + protected function isCurrentApiRequestRoot(): bool { return ApiRequest::isCurrentApiRequestTheRootApiRequest(); @@ -175,4 +188,18 @@ protected function createDisabledResponse(string|int|null $topLevelRequestId): R $topLevelRequestId, ); } + + protected function createPrivilegeTooHighResponse(string|int|null $topLevelRequestId): ResponseInterface + { + if ($topLevelRequestId === null) { + return (new Psr17Factory())->createResponse(403); + } + + return $this->jsonRpcErrorResponseFactory->create( + 403, + JsonRpcError::INVALID_REQUEST, + McpAccessLevel::createTooHighPrivilegeMessage($this->systemSettings->getMaximumAllowedMcpAccessLevel()), + $topLevelRequestId, + ); + } } diff --git a/README.md b/README.md index 760c236..b8331f0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ It provides analytics tools for sites, reports, processed report data, goals, se 2. Activate **McpServer** in **Administration -> Plugins**. 3. Enable MCP in **Administration -> System -> Plugin Settings -> McpServer**. 4. Configure your MCP client with the endpoint and a Matomo `token_auth` that already has access to the data you want to expose. +5. If needed, restrict the maximum allowed MCP privilege level in plugin settings or use a separate lower-privilege Matomo user for MCP access. For the recommended end-user setup flow, use the in-product connect guide at **Administration -> Platform -> MCP Server**. @@ -21,6 +22,7 @@ For the recommended end-user setup flow, use the in-product connect guide at **A - Raw Matomo API discovery and execution tools are separately disabled by default and must be enabled by an administrator. - The plugin uses Matomo authentication. - Data access is limited to the same sites and reports the Matomo user can already access. +- Administrators can optionally restrict MCP usage to users or tokens at or below a configured privilege level. - When raw API access is enabled, MCP clients can access the same Matomo API surface available to the authenticated user, including state-changing methods if an administrator has allowed them. - If features such as the Visitor Log are available to that user, MCP clients may access the same underlying data scope. - Review privacy, security, and compliance requirements before enabling raw API access. diff --git a/Support/Access/McpAccessLevel.php b/Support/Access/McpAccessLevel.php new file mode 100644 index 0000000..8d9a978 --- /dev/null +++ b/Support/Access/McpAccessLevel.php @@ -0,0 +1,111 @@ + + */ + public static function getConfigurableLevels(): array + { + return [ + self::UNLIMITED, + self::VIEW, + self::WRITE, + self::ADMIN, + ]; + } + + public static function normalizeMaximumAllowed(mixed $value): string + { + if (!is_scalar($value)) { + return self::UNLIMITED; + } + + $normalizedValue = strtolower(trim((string) $value)); + + return in_array($normalizedValue, self::getConfigurableLevels(), true) + ? $normalizedValue + : self::UNLIMITED; + } + + public static function resolveCurrentUserLevel(): string + { + $access = Access::getInstance(); + + if ($access->hasSuperUserAccess()) { + return self::SUPERUSER; + } + + if (Piwik::isUserHasSomeAdminAccess()) { + return self::ADMIN; + } + + if (Piwik::isUserHasSomeWriteAccess()) { + return self::WRITE; + } + + return self::VIEW; + } + + public static function exceedsMaximumAllowed(string $currentLevel, string $maximumAllowedLevel): bool + { + $normalizedMaximum = self::normalizeMaximumAllowed($maximumAllowedLevel); + + if ($normalizedMaximum === self::UNLIMITED) { + return false; + } + + return self::getRank($currentLevel) > self::getRank($normalizedMaximum); + } + + public static function getDisplayName(string $level): string + { + return match ($level) { + self::VIEW => 'View', + self::WRITE => 'Write', + self::ADMIN => 'Admin', + self::SUPERUSER => 'Superuser', + default => 'Unlimited', + }; + } + + public static function createTooHighPrivilegeMessage(string $maximumAllowedLevel): string + { + return sprintf( + McpEndpointSpec::TOO_HIGH_PRIVILEGE_ERROR, + self::getDisplayName(self::normalizeMaximumAllowed($maximumAllowedLevel)), + ); + } + + private static function getRank(string $level): int + { + return match ($level) { + self::VIEW => 1, + self::WRITE => 2, + self::ADMIN => 3, + self::SUPERUSER => 4, + default => 0, + }; + } +} diff --git a/Support/Api/McpEndpointSpec.php b/Support/Api/McpEndpointSpec.php index ed5a094..0d01a26 100644 --- a/Support/Api/McpEndpointSpec.php +++ b/Support/Api/McpEndpointSpec.php @@ -22,5 +22,7 @@ final class McpEndpointSpec . 'Nested API calls (including API.getBulkRequest) are not supported.'; public const UNAUTHORIZED_ERROR = 'Authentication required.'; public const DISABLED_ERROR = 'MCP endpoint is disabled. Please contact your Matomo administrator.'; + public const TOO_HIGH_PRIVILEGE_ERROR = + 'Authenticated MCP access has too high privilege level. Maximum of %s access level is allowed.'; public const INTERNAL_ERROR = 'Internal endpoint error.'; } diff --git a/SystemSettings.php b/SystemSettings.php index 2bf5061..0b16738 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -12,6 +12,7 @@ namespace Piwik\Plugins\McpServer; use Piwik\Piwik; +use Piwik\Plugins\McpServer\Support\Access\McpAccessLevel; use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Settings\FieldConfig; use Piwik\Settings\Setting; @@ -26,6 +27,9 @@ class SystemSettings extends \Piwik\Settings\Plugin\SystemSettings /** @var Setting */ public $enableMcp; + /** @var Setting */ + public $maximumMcpAccessLevel; + /** @var Setting */ public $rawApiAccessScope; @@ -63,6 +67,28 @@ function (FieldConfig $field) { }, ); + $this->maximumMcpAccessLevel = $this->makeSetting( + 'maximum_mcp_access_level', + McpAccessLevel::UNLIMITED, + FieldConfig::TYPE_STRING, + function (FieldConfig $field) { + $field->title = Piwik::translate('McpServer_MaximumMcpAccessLevelTitle'); + $field->inlineHelp = implode('

', [ + Piwik::translate('McpServer_MaximumMcpAccessLevelHelpPurpose'), + Piwik::translate('McpServer_MaximumMcpAccessLevelHelpTokens'), + Piwik::translate('McpServer_MaximumMcpAccessLevelHelpSeparateUser'), + ]); + $field->uiControl = FieldConfig::UI_CONTROL_SINGLE_SELECT; + $field->condition = 'enable_mcp==1'; + $field->availableValues = [ + McpAccessLevel::UNLIMITED => Piwik::translate('McpServer_MaximumMcpAccessLevelUnlimited'), + McpAccessLevel::VIEW => Piwik::translate('McpServer_MaximumMcpAccessLevelView'), + McpAccessLevel::WRITE => Piwik::translate('McpServer_MaximumMcpAccessLevelWrite'), + McpAccessLevel::ADMIN => Piwik::translate('McpServer_MaximumMcpAccessLevelAdmin'), + ]; + }, + ); + $sharedRawApiInlineHelp = implode('

', [ Piwik::translate('McpServer_RawApiAccessHelpPurpose'), Piwik::translate('McpServer_RawApiAccessHelpReadFallback'), @@ -138,6 +164,11 @@ public function isMcpEnabled(): bool return (bool) $this->enableMcp->getValue(); } + public function getMaximumAllowedMcpAccessLevel(): string + { + return McpAccessLevel::normalizeMaximumAllowed($this->maximumMcpAccessLevel->getValue()); + } + public function getRawApiAccessMode(): string { $scope = $this->normalizeRawApiAccessScope($this->rawApiAccessScope->getValue()); diff --git a/docs/faq.md b/docs/faq.md index e3eef84..141a1f2 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -38,6 +38,13 @@ Configure raw Matomo API tool access in **Administration -> System -> Plugin Set - Low-confidence or unclassified direct API methods require `Full API access`. - Direct API access can expose raw or personal data depending on enabled Matomo features. Review privacy and security requirements before enabling it, and consult your DPO or compliance owner when needed. +Configure MCP privilege limits in **Administration -> System -> Plugin Settings -> McpServer**: + +- Use **Maximum allowed MCP privilege level** to deny MCP access for users authenticated with a higher Matomo privilege. +- `No privilege limit` (default): follows the usual Matomo access model and does not add an extra MCP privilege cap. +- `View access`, `Write access`, or `Admin access`: allows only users whose highest privilege across all sites is at or below the selected level. +- For stricter separation, create a separate Matomo user or token with reduced permissions for MCP use. + ## Enabling MCP MCP access is disabled by default and must be enabled in **Administration -> System -> Plugin Settings -> McpServer**. @@ -62,5 +69,5 @@ The plugin is focused on read-oriented analytics workflows. The exact tool surfa ## Troubleshooting - `401 Unauthorized`: verify the Bearer token is present, active, and belongs to a user with access to the requested site data. -- `403 Forbidden`: if MCP is disabled, enable MCP in **Administration -> System -> Plugin Settings -> McpServer**. If MCP is already enabled, verify the authenticated Matomo user has access to the requested site or report data. +- `403 Forbidden`: if MCP is disabled, enable MCP in **Administration -> System -> Plugin Settings -> McpServer**. If MCP is already enabled, verify the authenticated Matomo user has access to the requested site or report data and does not exceed the configured maximum MCP privilege level. - `400 Bad Request`: verify the client is using the exact MCP endpoint and is not proxying requests through `API.getBulkRequest`. diff --git a/lang/en.json b/lang/en.json index 952aa89..9377409 100644 --- a/lang/en.json +++ b/lang/en.json @@ -32,6 +32,14 @@ "EnableMcpHelpPurpose": "Enable the Matomo MCP Server (Model Context Protocol) to allow AI tools and assistants to access analytics context from your Matomo instance.", "EnableMcpHelpUrl": "Your MCP URL: %1$s%2$s%3$s", "EnableMcpTitle": "Enable MCP Server (Model Context Protocol)", + "MaximumMcpAccessLevelAdmin": "Admin access", + "MaximumMcpAccessLevelHelpPurpose": "Choose the highest Matomo privilege level allowed to use the MCP endpoint. Users authenticated with a higher privilege level will be denied.", + "MaximumMcpAccessLevelHelpSeparateUser": "If you need tighter isolation, create a separate Matomo user for MCP with only the required site permissions.", + "MaximumMcpAccessLevelHelpTokens": "Use this setting to limit MCP access to lower-privilege users or tokens.", + "MaximumMcpAccessLevelTitle": "Maximum allowed MCP privilege level", + "MaximumMcpAccessLevelUnlimited": "No privilege limit", + "MaximumMcpAccessLevelView": "View access", + "MaximumMcpAccessLevelWrite": "Write access", "RawApiAccessHelpDataScope": "Direct Matomo API access can expose the same data available through the Matomo user interface or direct API endpoints, including raw or personal data when features such as the Visitor Log are enabled.", "RawApiAccessHelpDestructive": "Partial API access can enable create, update, and delete methods through the selected checkboxes below. Full API access can execute any allowed state-changing or destructive API methods, including actions that modify configuration or delete data.", "RawApiAccessHelpPolicy": "Before enabling direct API access, ensure this complies with your organization's privacy and security policies and applicable regulations. You may need approval from your data protection officer (DPO) or another compliance owner.", diff --git a/tests/Framework/McpAuthTestHelper.php b/tests/Framework/McpAuthTestHelper.php index a2f54fc..a7f67b2 100644 --- a/tests/Framework/McpAuthTestHelper.php +++ b/tests/Framework/McpAuthTestHelper.php @@ -65,41 +65,45 @@ public static function asNoAccessUser(callable $callback): mixed public static function asViewUserForSite(int $idSite, callable $callback): mixed { - $originalTokenAuth = self::captureCurrentTokenAuth(); - $previousForcedTokenAuth = self::$forcedTokenAuth; - $fixture = self::createViewUserFixture($idSite); - self::$forcedTokenAuth = $fixture['tokenAuth']; - self::switchToTokenAuth($fixture['tokenAuth']); - $callbackError = null; - $result = null; - - try { - $result = $callback(); - } catch (\Throwable $e) { - $callbackError = $e; - } finally { - $cleanupError = null; - - self::switchToSuperUser(); - try { - self::cleanupNoAccessUserFixture($fixture); - } catch (\Throwable $e) { - $cleanupError = $e; - } - - self::restoreAuth($originalTokenAuth); - self::$forcedTokenAuth = $previousForcedTokenAuth; + return self::asUserWithAccess( + $callback, + ['view' => [$idSite]], + 'MCP view access test token', + 'mcp_view_user', + ); + } - if ($callbackError !== null) { - throw $callbackError; - } + public static function asWriteUserForSite(int $idSite, callable $callback): mixed + { + return self::asUserWithAccess( + $callback, + ['write' => [$idSite]], + 'MCP write access test token', + 'mcp_write_user', + ); + } - if ($cleanupError !== null) { - throw new \RuntimeException('Failed cleaning up view-access user fixture.', 0, $cleanupError); - } - } + public static function asAdminUserForSite(int $idSite, callable $callback): mixed + { + return self::asUserWithAccess( + $callback, + ['admin' => [$idSite]], + 'MCP admin access test token', + 'mcp_admin_user', + ); + } - return $result; + /** + * @param array> $accessByLevel + */ + public static function asUserWithSiteAccessLevels(array $accessByLevel, callable $callback): mixed + { + return self::asUserWithAccess( + $callback, + $accessByLevel, + 'MCP custom access test token', + 'mcp_access_user', + ); } public static function getForcedTokenAuth(): ?string @@ -130,30 +134,6 @@ private static function createNoAccessUserFixture(?string $suffix = null): array ]; } - /** - * @return array{login: string, tokenAuth: string} - */ - private static function createViewUserFixture(int $idSite, ?string $suffix = null): array - { - $unique = $suffix ?? substr(hash('sha256', uniqid('', true)), 0, 12); - $login = 'mcp_view_user_' . $unique; - $tokenAuth = (new UsersManagerModel())->generateRandomTokenAuth(); - - UsersManagerApi::getInstance()->addUser($login, 'mcp-view-password', $login . '@example.test'); - (new UsersManagerModel())->addTokenAuth( - $login, - $tokenAuth, - 'MCP view access test token', - Date::now()->getDatetime(), - ); - UsersManagerApi::getInstance()->setUserAccess($login, 'view', [$idSite]); - - return [ - 'login' => $login, - 'tokenAuth' => $tokenAuth, - ]; - } - public static function switchToTokenAuth(string $tokenAuth): void { Piwik::postEvent('Request.initAuthenticationObject'); @@ -208,4 +188,86 @@ private static function switchToSuperUser(): void $access->setSuperUserAccess(true); $access->reloadAccess(StaticContainer::get('Piwik\Auth')); } + + /** + * @param array> $accessByLevel + */ + private static function asUserWithAccess( + callable $callback, + array $accessByLevel, + string $tokenDescription, + string $loginPrefix, + ): mixed { + $originalTokenAuth = self::captureCurrentTokenAuth(); + $previousForcedTokenAuth = self::$forcedTokenAuth; + $fixture = self::createUserFixture($accessByLevel, $tokenDescription, $loginPrefix); + self::$forcedTokenAuth = $fixture['tokenAuth']; + self::switchToTokenAuth($fixture['tokenAuth']); + $callbackError = null; + $result = null; + + try { + $result = $callback(); + } catch (\Throwable $e) { + $callbackError = $e; + } finally { + $cleanupError = null; + + self::switchToSuperUser(); + try { + self::cleanupNoAccessUserFixture($fixture); + } catch (\Throwable $e) { + $cleanupError = $e; + } + + self::restoreAuth($originalTokenAuth); + self::$forcedTokenAuth = $previousForcedTokenAuth; + + if ($callbackError !== null) { + throw $callbackError; + } + + if ($cleanupError !== null) { + throw new \RuntimeException('Failed cleaning up user access fixture.', 0, $cleanupError); + } + } + + return $result; + } + + /** + * @param array> $accessByLevel + * @return array{login: string, tokenAuth: string} + */ + private static function createUserFixture( + array $accessByLevel, + string $tokenDescription, + string $loginPrefix, + ?string $suffix = null, + ): array { + $unique = $suffix ?? substr(hash('sha256', uniqid('', true)), 0, 12); + $login = $loginPrefix . '_' . $unique; + $tokenAuth = (new UsersManagerModel())->generateRandomTokenAuth(); + + UsersManagerApi::getInstance()->addUser($login, 'mcp-access-password', $login . '@example.test'); + (new UsersManagerModel())->addTokenAuth( + $login, + $tokenAuth, + $tokenDescription, + Date::now()->getDatetime(), + ); + + foreach ($accessByLevel as $accessLevel => $idSites) { + if ($idSites === []) { + continue; + } + + UsersManagerApi::getInstance()->setUserAccess($login, $accessLevel, $idSites); + } + + return [ + 'login' => $login, + 'tokenAuth' => $tokenAuth, + ]; + } } diff --git a/tests/Framework/McpTestHelper.php b/tests/Framework/McpTestHelper.php index 2f3535b..a31d2de 100644 --- a/tests/Framework/McpTestHelper.php +++ b/tests/Framework/McpTestHelper.php @@ -35,6 +35,7 @@ use Piwik\Access; use Piwik\Container\StaticContainer; use Piwik\Plugins\McpServer\McpServerFactory; +use Piwik\Plugins\McpServer\Support\Access\McpAccessLevel; use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\SystemSettings; @@ -107,6 +108,26 @@ public static function setRawApiAccessMode(string $rawApiAccessMode): void } } + public static function getMaximumAllowedMcpAccessLevel(): string + { + return StaticContainer::get(SystemSettings::class)->getMaximumAllowedMcpAccessLevel(); + } + + public static function setMaximumAllowedMcpAccessLevel(string $maximumAllowedMcpAccessLevel): void + { + $access = Access::getInstance(); + $hadSuperUserAccess = $access->hasSuperUserAccess(); + $access->setSuperUserAccess(true); + + try { + StaticContainer::get(SystemSettings::class)->maximumMcpAccessLevel->setValue( + McpAccessLevel::normalizeMaximumAllowed($maximumAllowedMcpAccessLevel), + ); + } finally { + $access->setSuperUserAccess($hadSuperUserAccess); + } + } + /** * @param array|array|string $payload * @param array $headers diff --git a/tests/Integration/McpApiEndpointBoundaryTest.php b/tests/Integration/McpApiEndpointBoundaryTest.php index a8a910e..becfeb0 100644 --- a/tests/Integration/McpApiEndpointBoundaryTest.php +++ b/tests/Integration/McpApiEndpointBoundaryTest.php @@ -20,12 +20,15 @@ use Piwik\FrontController; use Piwik\Plugins\McpServer\API; use Piwik\Plugins\McpServer\McpServerFactory; +use Piwik\Plugins\McpServer\Support\Access\McpAccessLevel; use Piwik\Plugins\McpServer\Support\Api\JsonRpcErrorResponseFactory; use Piwik\Plugins\McpServer\Support\Api\JsonRpcRequestIdExtractor; use Piwik\Plugins\McpServer\Support\Api\McpEndpointGuard; use Piwik\Plugins\McpServer\Support\Api\McpEndpointSpec; use Piwik\Plugins\McpServer\SystemSettings; +use Piwik\Plugins\McpServer\tests\Framework\McpAuthTestHelper; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; +use Piwik\Tests\Framework\Fixture; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; /** @@ -43,6 +46,12 @@ class McpApiEndpointBoundaryTest extends IntegrationTestCase private bool $originalEnableMcpValue = false; + private string $originalMaximumAllowedMcpAccessLevel = McpAccessLevel::UNLIMITED; + + private int $idSite = 0; + + private int $idSiteOther = 0; + public function setUp(): void { parent::setUp(); @@ -50,12 +59,22 @@ public function setUp(): void $this->originalNestedApiInvocationCount = $this->getNestedApiInvocationCount(); $this->originalRootApiMethod = (string) ApiRequest::getRootApiRequestMethod(); $this->originalEnableMcpValue = (bool) StaticContainer::get(SystemSettings::class)->enableMcp->getValue(); + $this->originalMaximumAllowedMcpAccessLevel = StaticContainer::get(SystemSettings::class) + ->getMaximumAllowedMcpAccessLevel(); + $this->idSite = Fixture::createWebsite('2010-01-01 00:00:00', 0, 'Boundary Test Site', 'https://boundary.test'); + $this->idSiteOther = Fixture::createWebsite( + '2010-01-01 00:00:00', + 0, + 'Boundary Other Site', + 'https://boundary-other.test', + ); } public function tearDown(): void { $_GET = $this->originalGet; $this->setMcpEnabled($this->originalEnableMcpValue); + $this->setMaximumAllowedMcpAccessLevel($this->originalMaximumAllowedMcpAccessLevel); $this->setNestedApiInvocationCount($this->originalNestedApiInvocationCount); ApiRequest::setIsRootRequestApiRequest($this->originalRootApiMethod); Access::getInstance()->setSuperUserAccess(false); @@ -224,6 +243,124 @@ public function testDisabledMcpReturnsForbiddenWithEmptyBodyWhenTopLevelIdMissin self::assertSame('', McpTestHelper::getResponseBody($response)); } + public function testPrivilegeCapAllowsViewUserWhenMaximumIsView(): void + { + $this->setMcpEnabled(true); + $this->setMaximumAllowedMcpAccessLevel(McpAccessLevel::VIEW); + + $_GET['module'] = 'API'; + $_GET['method'] = 'McpServer.mcp'; + $_GET['format'] = 'mcp'; + + McpAuthTestHelper::asViewUserForSite($this->idSite, function (): void { + $api = $this->createApiWithRequest($this->createRequest(McpTestHelper::makeInitializeRequest('view-1'))); + $response = $api->mcp(); + + self::assertSame(200, $response->getStatusCode()); + McpTestHelper::decodeResponse($response); + }); + } + + public function testPrivilegeCapRejectsWriteUserWhenMaximumIsView(): void + { + $this->setMcpEnabled(true); + $this->setMaximumAllowedMcpAccessLevel(McpAccessLevel::VIEW); + + $_GET['module'] = 'API'; + $_GET['method'] = 'McpServer.mcp'; + $_GET['format'] = 'mcp'; + + McpAuthTestHelper::asWriteUserForSite($this->idSite, function (): void { + $api = $this->createApiWithRequest($this->createRequest(McpTestHelper::makeInitializeRequest('write-1'))); + $response = $api->mcp(); + $error = McpTestHelper::decodeError($response); + + self::assertSame(403, $response->getStatusCode()); + self::assertSame(JsonRpcError::INVALID_REQUEST, $error->code); + self::assertSame( + 'Authenticated MCP access has too high privilege level. Maximum of View access level is allowed.', + $error->message, + ); + self::assertSame('write-1', $error->id); + }); + } + + public function testPrivilegeCapRejectsAdminUserWhenMaximumIsWrite(): void + { + $this->setMcpEnabled(true); + $this->setMaximumAllowedMcpAccessLevel(McpAccessLevel::WRITE); + + $_GET['module'] = 'API'; + $_GET['method'] = 'McpServer.mcp'; + $_GET['format'] = 'mcp'; + + McpAuthTestHelper::asAdminUserForSite($this->idSite, function (): void { + $api = $this->createApiWithRequest($this->createRequest(McpTestHelper::makeInitializeRequest('admin-1'))); + $response = $api->mcp(); + $error = McpTestHelper::decodeError($response); + + self::assertSame(403, $response->getStatusCode()); + self::assertSame(JsonRpcError::INVALID_REQUEST, $error->code); + self::assertSame( + 'Authenticated MCP access has too high privilege level. Maximum of Write access level is allowed.', + $error->message, + ); + self::assertSame('admin-1', $error->id); + }); + } + + public function testPrivilegeCapUsesHighestPrivilegeAcrossSites(): void + { + $this->setMcpEnabled(true); + $this->setMaximumAllowedMcpAccessLevel(McpAccessLevel::WRITE); + + $_GET['module'] = 'API'; + $_GET['method'] = 'McpServer.mcp'; + $_GET['format'] = 'mcp'; + + McpAuthTestHelper::asUserWithSiteAccessLevels( + ['view' => [$this->idSite], 'admin' => [$this->idSiteOther]], + function (): void { + $api = $this->createApiWithRequest( + $this->createRequest(McpTestHelper::makeInitializeRequest('mixed-1')), + ); + $response = $api->mcp(); + $error = McpTestHelper::decodeError($response); + + self::assertSame(403, $response->getStatusCode()); + self::assertSame(JsonRpcError::INVALID_REQUEST, $error->code); + self::assertSame( + 'Authenticated MCP access has too high privilege level. Maximum of Write access level is allowed.', + $error->message, + ); + self::assertSame('mixed-1', $error->id); + }, + ); + } + + public function testPrivilegeCapRejectsSuperUserWhenMaximumIsAdmin(): void + { + $this->setMcpEnabled(true); + $this->setMaximumAllowedMcpAccessLevel(McpAccessLevel::ADMIN); + Access::getInstance()->setSuperUserAccess(true); + + $_GET['module'] = 'API'; + $_GET['method'] = 'McpServer.mcp'; + $_GET['format'] = 'mcp'; + + $api = $this->createApiWithRequest($this->createRequest(McpTestHelper::makeInitializeRequest('superuser-1'))); + $response = $api->mcp(); + $error = McpTestHelper::decodeError($response); + + self::assertSame(403, $response->getStatusCode()); + self::assertSame(JsonRpcError::INVALID_REQUEST, $error->code); + self::assertSame( + 'Authenticated MCP access has too high privilege level. Maximum of Admin access level is allowed.', + $error->message, + ); + self::assertSame('superuser-1', $error->id); + } + private function createRequest(string $payload): ServerRequestInterface { $factory = new Psr17Factory(); @@ -311,4 +448,17 @@ private function setMcpEnabled(bool $isEnabled): void Access::getInstance()->setSuperUserAccess($hadSuperUserAccess); } } + + private function setMaximumAllowedMcpAccessLevel(string $maximumAllowedMcpAccessLevel): void + { + $settings = StaticContainer::get(SystemSettings::class); + $hadSuperUserAccess = Access::getInstance()->hasSuperUserAccess(); + Access::getInstance()->setSuperUserAccess(true); + + try { + $settings->maximumMcpAccessLevel->setValue($maximumAllowedMcpAccessLevel); + } finally { + Access::getInstance()->setSuperUserAccess($hadSuperUserAccess); + } + } } diff --git a/tests/Integration/McpServerTest.php b/tests/Integration/McpServerTest.php index f0f7899..71fc1ba 100644 --- a/tests/Integration/McpServerTest.php +++ b/tests/Integration/McpServerTest.php @@ -15,6 +15,7 @@ use Piwik\Access; use Piwik\Container\StaticContainer; use Piwik\Plugin\Manager; +use Piwik\Plugins\McpServer\Support\Access\McpAccessLevel; use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\SystemSettings; use Piwik\Plugins\McpServer\tests\Framework\McpAuthTestHelper; @@ -131,6 +132,7 @@ public function testContainerSystemSettingCanBeToggled(): void $systemSettings = StaticContainer::get(SystemSettings::class); self::assertInstanceOf(SystemSettings::class, $systemSettings); $originalEnableMcpValue = (bool) $systemSettings->enableMcp->getValue(); + $originalMaximumAllowedMcpAccessLevel = $systemSettings->getMaximumAllowedMcpAccessLevel(); $originalRawApiAccessMode = $systemSettings->getRawApiAccessMode(); Access::getInstance()->setSuperUserAccess(true); @@ -142,6 +144,15 @@ public function testContainerSystemSettingCanBeToggled(): void $systemSettings->enableMcp->setValue(true); self::assertTrue($systemSettings->isMcpEnabled()); + $systemSettings->maximumMcpAccessLevel->setValue(McpAccessLevel::VIEW); + self::assertSame(McpAccessLevel::VIEW, $systemSettings->getMaximumAllowedMcpAccessLevel()); + + $systemSettings->maximumMcpAccessLevel->setValue(McpAccessLevel::WRITE); + self::assertSame(McpAccessLevel::WRITE, $systemSettings->getMaximumAllowedMcpAccessLevel()); + + $systemSettings->maximumMcpAccessLevel->setValue(McpAccessLevel::ADMIN); + self::assertSame(McpAccessLevel::ADMIN, $systemSettings->getMaximumAllowedMcpAccessLevel()); + $this->applyRawApiAccessMode($systemSettings, RawApiAccessMode::READ); self::assertSame('read', $systemSettings->getRawApiAccessMode()); @@ -158,6 +169,7 @@ public function testContainerSystemSettingCanBeToggled(): void self::assertSame('full', $systemSettings->getRawApiAccessMode()); } finally { $systemSettings->enableMcp->setValue($originalEnableMcpValue); + $systemSettings->maximumMcpAccessLevel->setValue($originalMaximumAllowedMcpAccessLevel); $this->applyRawApiAccessMode($systemSettings, $originalRawApiAccessMode); Access::getInstance()->setSuperUserAccess(false); } diff --git a/tests/Integration/SystemSettingsTest.php b/tests/Integration/SystemSettingsTest.php index 7645e8f..264f56a 100644 --- a/tests/Integration/SystemSettingsTest.php +++ b/tests/Integration/SystemSettingsTest.php @@ -12,6 +12,7 @@ namespace Piwik\Plugins\McpServer\tests\Integration; use Piwik\Access; +use Piwik\Plugins\McpServer\Support\Access\McpAccessLevel; use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\SystemSettings; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -24,6 +25,7 @@ class SystemSettingsTest extends IntegrationTestCase { private ?SystemSettings $settings = null; private bool $originalEnableMcp = false; + private string $originalMaximumAllowedMcpAccessLevel = McpAccessLevel::UNLIMITED; private string $originalRawApiAccessMode = RawApiAccessMode::NONE; public function setUp(): void @@ -33,6 +35,7 @@ public function setUp(): void $this->settings = new SystemSettings(); self::assertInstanceOf(SystemSettings::class, $this->settings); $this->originalEnableMcp = $this->settings->isMcpEnabled(); + $this->originalMaximumAllowedMcpAccessLevel = $this->settings->getMaximumAllowedMcpAccessLevel(); $this->originalRawApiAccessMode = $this->settings->getRawApiAccessMode(); } @@ -44,6 +47,7 @@ public function tearDown(): void try { $this->settings->enableMcp->setValue($this->originalEnableMcp); + $this->settings->maximumMcpAccessLevel->setValue($this->originalMaximumAllowedMcpAccessLevel); $this->applyRawApiAccessMode($this->originalRawApiAccessMode); } finally { Access::getInstance()->setSuperUserAccess($hadSuperUserAccess); @@ -73,6 +77,33 @@ public function testCanEnableMcp(): void } } + public function testMaximumAllowedMcpAccessLevelDefaultsToUnlimited(): void + { + self::assertInstanceOf(SystemSettings::class, $this->settings); + self::assertSame(McpAccessLevel::UNLIMITED, $this->settings->getMaximumAllowedMcpAccessLevel()); + } + + public function testCanChangeMaximumAllowedMcpAccessLevel(): void + { + self::assertInstanceOf(SystemSettings::class, $this->settings); + $access = Access::getInstance(); + $hadSuperUserAccess = $access->hasSuperUserAccess(); + $access->setSuperUserAccess(true); + + try { + $this->settings->maximumMcpAccessLevel->setValue(McpAccessLevel::VIEW); + self::assertSame(McpAccessLevel::VIEW, $this->settings->getMaximumAllowedMcpAccessLevel()); + + $this->settings->maximumMcpAccessLevel->setValue(McpAccessLevel::WRITE); + self::assertSame(McpAccessLevel::WRITE, $this->settings->getMaximumAllowedMcpAccessLevel()); + + $this->settings->maximumMcpAccessLevel->setValue(McpAccessLevel::ADMIN); + self::assertSame(McpAccessLevel::ADMIN, $this->settings->getMaximumAllowedMcpAccessLevel()); + } finally { + $access->setSuperUserAccess($hadSuperUserAccess); + } + } + public function testRawApiAccessModeDefaultsToNone(): void { self::assertInstanceOf(SystemSettings::class, $this->settings); diff --git a/tests/UI/McpServer_spec.js b/tests/UI/McpServer_spec.js index 2564cbe..3375f20 100644 --- a/tests/UI/McpServer_spec.js +++ b/tests/UI/McpServer_spec.js @@ -14,6 +14,7 @@ describe('McpServer', function () { const connectUrl = '?module=McpServer&action=connect&idSite=1&period=day&date=yesterday'; const settingsSelector = '#McpServerPluginSettings'; const enabledCheckboxSelector = 'input[name="enable_mcp"]'; + const maximumMcpAccessLevelSelector = 'select[name="maximum_mcp_access_level"]'; const rawApiAccessScopeSelector = 'select[name="raw_api_access_scope"]'; const settingsSaveButtonSelector = `${settingsSelector} .pluginsSettingsSubmit`; const connectSelector = '.mcpServerConnect'; @@ -68,7 +69,12 @@ describe('McpServer', function () { }, rawApiAccessScopeSelector); } - async function configureMcp(enabled, rawApiAccessScope = 'string:partial', rawApiAccessLevels = []) + async function configureMcp( + enabled, + maximumMcpAccessLevel = 'string:unlimited', + rawApiAccessScope = 'string:partial', + rawApiAccessLevels = [] + ) { resetUserToSuperUser(); await page.goto(settingsUrl); @@ -86,11 +92,23 @@ describe('McpServer', function () { } if (enabled) { + await page.waitForSelector(maximumMcpAccessLevelSelector, { visible: true }); await page.waitForSelector(rawApiAccessScopeSelector, { visible: true }); + const currentMaximumMcpAccessLevel = await page.$eval(maximumMcpAccessLevelSelector, (el) => el.value); const currentRawApiAccessScope = await page.$eval(rawApiAccessScopeSelector, (el) => el.value); + let didChangeSetting = false; + + if (currentMaximumMcpAccessLevel !== maximumMcpAccessLevel) { + await page.select(maximumMcpAccessLevelSelector, maximumMcpAccessLevel); + didChangeSetting = true; + } if (currentRawApiAccessScope !== rawApiAccessScope) { await page.select(rawApiAccessScopeSelector, rawApiAccessScope); + didChangeSetting = true; + } + + if (didChangeSetting) { await page.waitForTimeout(250); await saveSettings(); } @@ -144,9 +162,10 @@ describe('McpServer', function () { }); it('should display the plugin settings when MCP is enabled with partial API access', async function () { - await configureMcp(true, 'string:partial', ['read']); + await configureMcp(true, 'string:view', 'string:partial', ['read']); expect(await isRawApiAccessScopeVisible()).to.equal(true); + expect(await page.$eval(maximumMcpAccessLevelSelector, (el) => el.value)).to.equal('string:view'); expect(await page.$eval(rawApiAccessScopeSelector, (el) => el.value)).to.equal('string:partial'); expect(await page.$eval('input[name="raw_api_access_read"]', (el) => !!el.checked)).to.equal(true); expect(await page.screenshotSelector(settingsSelector)).to.matchImage('settings'); @@ -188,7 +207,7 @@ describe('McpServer', function () { }); it('should display the connect page when MCP is enabled', async function () { - await configureMcp(true, 'string:full'); + await configureMcp(true, 'string:unlimited', 'string:full'); await page.goto(connectUrl); await page.waitForNetworkIdle(); await page.waitForSelector(connectSelector, { visible: true }); diff --git a/tests/Unit/APITest.php b/tests/Unit/APITest.php index 454c084..2e5de3d 100644 --- a/tests/Unit/APITest.php +++ b/tests/Unit/APITest.php @@ -146,12 +146,38 @@ public function testMcpRejectsApiBulkRequestContext(): void public function testMcpReturnsUnauthorizedChallengeWhenNoViewAccess(): void { - Config::getInstance()->McpServer = ['log_tool_calls' => 1]; $_GET['module'] = 'API'; $_GET['method'] = 'McpServer.mcp'; $_GET['format'] = 'mcp'; - $api = $this->createApiWithRequest($this->createRequest()); + $factory = $this->createFactory(); + + $api = $this + ->getMockBuilder(API::class) + ->setConstructorArgs([ + $factory, + new McpEndpointGuard(), + new JsonRpcErrorResponseFactory(), + new JsonRpcRequestIdExtractor(), + $this->createMock(SystemSettings::class), + ]) + ->onlyMethods([ + 'createRequestFromGlobals', + 'isCurrentApiRequestRoot', + 'getRootApiRequestMethod', + 'checkUserHasSomeViewAccess', + ]) + ->getMock(); + + $api->method('createRequestFromGlobals') + ->willReturn($this->createRequest()); + $api->method('isCurrentApiRequestRoot') + ->willReturn(true); + $api->method('getRootApiRequestMethod') + ->willReturn('McpServer.mcp'); + $api->method('checkUserHasSomeViewAccess') + ->willThrowException(new \Piwik\NoAccessException('No access')); + $result = $api->mcp(); self::assertInstanceOf(ResponseInterface::class, $result); @@ -246,6 +272,45 @@ public function testMcpReturnsForbiddenWithoutBodyWhenMcpIsDisabledAndTopLevelId self::assertSame('', McpTestHelper::getResponseBody($response)); } + public function testMcpReturnsForbiddenErrorWhenPrivilegeLevelIsTooHighAndTopLevelIdExists(): void + { + Access::getInstance()->setSuperUserAccess(true); + $_GET['module'] = 'API'; + $_GET['method'] = 'McpServer.mcp'; + $_GET['format'] = 'mcp'; + + $request = $this->createRequest(McpTestHelper::makeInitializeRequest('privilege-1')); + $api = $this->createApiWithRequest($request, true, 'McpServer.mcp', true, false); + $response = $api->mcp(); + + self::assertSame(403, $response->getStatusCode()); + $message = McpTestHelper::decodeError($response); + self::assertSame(JsonRpcError::INVALID_REQUEST, $message->code); + self::assertSame( + 'Authenticated MCP access has too high privilege level. Maximum of Write access level is allowed.', + $message->message, + ); + self::assertSame('privilege-1', $message->id); + } + + public function testMcpReturnsForbiddenWithoutBodyWhenPrivilegeLevelIsTooHighAndTopLevelIdIsMissing(): void + { + Access::getInstance()->setSuperUserAccess(true); + $_GET['module'] = 'API'; + $_GET['method'] = 'McpServer.mcp'; + $_GET['format'] = 'mcp'; + + $initialize = \json_decode(McpTestHelper::makeInitializeRequest('batch-1'), true, 512, \JSON_THROW_ON_ERROR); + $batchPayload = \json_encode([$initialize], \JSON_THROW_ON_ERROR); + $request = $this->createRequest($batchPayload); + $api = $this->createApiWithRequest($request, true, 'McpServer.mcp', true, false); + $response = $api->mcp(); + + self::assertSame(403, $response->getStatusCode()); + self::assertSame('', $response->getHeaderLine('Content-Type')); + self::assertSame('', McpTestHelper::getResponseBody($response)); + } + public function testMcpReturnsInternalErrorResponseWhenRequestCreationFails(): void { $_GET['module'] = 'API'; @@ -310,8 +375,12 @@ private function createApiWithRequest( bool $isRootApiRequest = true, ?string $rootApiMethod = 'McpServer.mcp', bool $isMcpEnabled = true, + bool $isCurrentUserPrivilegeLevelAllowed = true, ): API { $factory = $this->createFactory(); + $systemSettings = $this->createMock(SystemSettings::class); + $systemSettings->method('getMaximumAllowedMcpAccessLevel') + ->willReturn('write'); $api = $this ->getMockBuilder(API::class) @@ -320,13 +389,14 @@ private function createApiWithRequest( new McpEndpointGuard(), new JsonRpcErrorResponseFactory(), new JsonRpcRequestIdExtractor(), - $this->createMock(SystemSettings::class), + $systemSettings, ]) ->onlyMethods([ 'createRequestFromGlobals', 'isCurrentApiRequestRoot', 'getRootApiRequestMethod', 'isMcpEnabled', + 'isCurrentUserPrivilegeLevelAllowed', ]) ->getMock(); @@ -338,6 +408,8 @@ private function createApiWithRequest( ->willReturn($rootApiMethod); $api->method('isMcpEnabled') ->willReturn($isMcpEnabled); + $api->method('isCurrentUserPrivilegeLevelAllowed') + ->willReturn($isCurrentUserPrivilegeLevelAllowed); return $api; } diff --git a/tests/Unit/Support/Access/McpAccessLevelTest.php b/tests/Unit/Support/Access/McpAccessLevelTest.php new file mode 100644 index 0000000..979e753 --- /dev/null +++ b/tests/Unit/Support/Access/McpAccessLevelTest.php @@ -0,0 +1,44 @@ + Date: Mon, 13 Apr 2026 19:36:45 +0200 Subject: [PATCH 14/15] Update expected screenshots --- .../McpServer_settings.png | Bin 70583 -> 222743 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/UI/expected-ui-screenshots/McpServer_settings.png b/tests/UI/expected-ui-screenshots/McpServer_settings.png index 954b2b30a7e06865b0514d03683a5f4b1fef4eb0..c603ba5ff897141dcbfb4fe38378d3e196c222ec 100644 GIT binary patch literal 222743 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 z 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 Date: Tue, 14 Apr 2026 22:03:52 +0200 Subject: [PATCH 15/15] Sort translations --- lang/en.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lang/en.json b/lang/en.json index 9377409..a303989 100644 --- a/lang/en.json +++ b/lang/en.json @@ -8,13 +8,15 @@ "ConnectInClientStepEndpoint": "Set the MCP server URL.", "ConnectInClientStepInitialize": "Save the configuration and connect.", "ConnectInClientStepOpenSettings": "Open your MCP client settings and find the MCP server configuration.", - "ConnectInClientTokenHelp": "In Matomo, go to: %1$sPersonal Settings -> Security -> Auth tokens%2$s. Create a token (or reuse an existing one) and use it as your %3$stoken_auth%4$s. %5$sLearn more about auth tokens%6$s.", "ConnectInClientTitle": "Connect Your MCP Client", + "ConnectInClientTokenHelp": "In Matomo, go to: %1$sPersonal Settings -> Security -> Auth tokens%2$s. Create a token (or reuse an existing one) and use it as your %3$stoken_auth%4$s. %5$sLearn more about auth tokens%6$s.", "ConnectIntro": "Use this guide to connect any MCP client to your Matomo MCP Server.", "ConnectMcpDisabledCanEnable": "MCP Server is currently disabled. %1$sEnable it in Plugin Settings%2$s to start accepting MCP requests.", "ConnectMcpDisabledNoPermission": "MCP Server is currently disabled.", + "ConnectNeedEndpoint": "The MCP server endpoint (URL)", + "ConnectNeedToken": "A Matomo %1$stoken_auth%2$s (used as a Bearer token)", "ConnectPageTitle": "How to Connect to the MCP Server", - "PlatformMenu": "MCP Server", + "ConnectTokenTitle": "Get a Matomo Token", "ConnectTroubleshooting400": "Request shape or endpoint usage is invalid (for example wrong endpoint parameters).", "ConnectTroubleshooting400Action": "Re-check the endpoint URL and that your client sends valid MCP requests.", "ConnectTroubleshooting401": "Missing or invalid authentication token.", @@ -22,12 +24,9 @@ "ConnectTroubleshooting403": "MCP Server is disabled or the authenticated Matomo user does not have access to the requested data.", "ConnectTroubleshooting403Action": "Confirm MCP is enabled in your Matomo instance. If it is already enabled, verify the token belongs to a user with access to the requested site or report data.", "ConnectTroubleshootingTitle": "Troubleshooting", - "ConnectTokenTitle": "Get a Matomo Token", "ConnectWhatYouNeedTitle": "What You'll Need", - "ConnectNeedEndpoint": "The MCP server endpoint (URL)", - "ConnectNeedToken": "A Matomo %1$stoken_auth%2$s (used as a Bearer token)", - "EnableMcpHelpDataScope": "The data accessible through the MCP Server is the same data that can be accessed through the Matomo user interface or Reporting API, including raw data if features such as the Visitor Log are enabled.", "EnableMcpHelpConnectGuide": "%1$sHow to connect an MCP client%2$s", + "EnableMcpHelpDataScope": "The data accessible through the MCP Server is the same data that can be accessed through the Matomo user interface or Reporting API, including raw data if features such as the Visitor Log are enabled.", "EnableMcpHelpPolicy": "Before enabling, ensure this complies with your organization's privacy policies and applicable regulations. You may need approval from your data protection officer (DPO) or to update your privacy policy.", "EnableMcpHelpPurpose": "Enable the Matomo MCP Server (Model Context Protocol) to allow AI tools and assistants to access analytics context from your Matomo instance.", "EnableMcpHelpUrl": "Your MCP URL: %1$s%2$s%3$s", @@ -40,13 +39,14 @@ "MaximumMcpAccessLevelUnlimited": "No privilege limit", "MaximumMcpAccessLevelView": "View access", "MaximumMcpAccessLevelWrite": "Write access", + "PlatformMenu": "MCP Server", + "RawApiAccessCreateTitle": "Create methods", + "RawApiAccessDeleteTitle": "Delete methods", "RawApiAccessHelpDataScope": "Direct Matomo API access can expose the same data available through the Matomo user interface or direct API endpoints, including raw or personal data when features such as the Visitor Log are enabled.", "RawApiAccessHelpDestructive": "Partial API access can enable create, update, and delete methods through the selected checkboxes below. Full API access can execute any allowed state-changing or destructive API methods, including actions that modify configuration or delete data.", "RawApiAccessHelpPolicy": "Before enabling direct API access, ensure this complies with your organization's privacy and security policies and applicable regulations. You may need approval from your data protection officer (DPO) or another compliance owner.", "RawApiAccessHelpPurpose": "Choose whether the direct raw Matomo API tools are hidden, exposed for partial API access, or exposed with full API access. When Partial API access is selected, use the checkboxes below to choose which CRUD categories are available.", "RawApiAccessHelpReadFallback": "Direct API access uses CRUD-style classification for discovered API methods. In Partial API access mode, only the checked CRUD categories are available. Dedicated report tools remain available independently, permanently restricted APIs stay blocked in every mode, and low-confidence methods require Full API access.", - "RawApiAccessCreateTitle": "Create methods", - "RawApiAccessDeleteTitle": "Delete methods", "RawApiAccessReadTitle": "Read methods", "RawApiAccessScopeFull": "Full API access", "RawApiAccessScopeNone": "No API access",