From 3e030d77224453274bba203f04f0480ede8751e2 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Wed, 4 Mar 2026 21:29:31 +0100 Subject: [PATCH 01/13] 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 | 3 + 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, 1680 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 bd4d96b..a16504a 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\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\CompatibleCallToolHandler; 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, + ); + } + $callToolHandler = new CompatibleCallToolHandler( $registry, new ReferenceHandler($this->container), @@ -134,6 +162,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), diff --git a/docs/faq.md b/docs/faq.md index 0dabaaa..c2c1640 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -28,6 +28,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 -> General 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 b5abaa4..5196e27 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/13] McpTool: matomo_api_get --- .../ApiMethodSummaryQueryServiceInterface.php | 7 + McpServerFactory.php | 19 ++ McpTools/ApiGet.php | 42 ++++ Schemas/Api/ApiGetToolInputSchema.php | 65 ++++++ Services/Api/ApiMethodSummaryQueryService.php | 62 +++++ 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 ++++ 15 files changed, 712 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 a16504a..51d2298 100644 --- a/McpServerFactory.php +++ b/McpServerFactory.php @@ -20,7 +20,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\CompatibleCallToolHandler; @@ -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/docs/faq.md b/docs/faq.md index c2c1640..596e5fd 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -35,10 +35,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 dc7c49a..d8a9b7f 100644 --- a/tests/Integration/McpTools/ReportMetadataTest.php +++ b/tests/Integration/McpTools/ReportMetadataTest.php @@ -605,6 +605,10 @@ public function testSchemaDeclaresSelectorRulesWithoutTopLevelCombinators(): voi 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); $apiParameters = $properties['apiParameters'] ?? null; diff --git a/tests/Integration/McpTools/ReportProcessedTest.php b/tests/Integration/McpTools/ReportProcessedTest.php index ca25d44..d911d68 100644 --- a/tests/Integration/McpTools/ReportProcessedTest.php +++ b/tests/Integration/McpTools/ReportProcessedTest.php @@ -610,6 +610,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 5196e27..a68bf7e 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 e5f4542482fa7911f0ba51d25f61dc15f9c63371 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Thu, 12 Mar 2026 00:12:34 +0100 Subject: [PATCH 03/13] 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 | 6 + 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, 1530 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 51d2298..b7b6858 100644 --- a/McpServerFactory.php +++ b/McpServerFactory.php @@ -20,8 +20,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 617c705..c8a9d97 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; @@ -27,7 +29,9 @@ use Piwik\Plugins\McpServer\Contracts\Ports\Sites\SiteDetailQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\Sites\SiteSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; +use Piwik\Plugins\McpServer\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; @@ -51,7 +55,9 @@ use Piwik\Plugins\McpServer\Session\DbSessionStore; 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), diff --git a/docs/faq.md b/docs/faq.md index 596e5fd..a1b26fe 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -35,10 +35,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 a68bf7e..73a034d 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 4cb93cb717b6db157c1e6cd3e4e7df063cb0bd98 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Wed, 8 Apr 2026 16:33:18 +0200 Subject: [PATCH 04/13] 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/13] 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 | 36 ++++++++-- 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 | 57 ++++++++++++--- 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, 389 insertions(+), 185 deletions(-) diff --git a/McpServerFactory.php b/McpServerFactory.php index b7b6858..692e1ae 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( @@ -211,12 +212,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 e4ac7ad..2d8dd29 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -13,6 +13,7 @@ use Piwik\Piwik; use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; +use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Settings\FieldConfig; use Piwik\Settings\Setting; use Piwik\SettingsPiwik; @@ -22,15 +23,13 @@ class SystemSettings extends \Piwik\Settings\Plugin\SystemSettings /** @var Setting */ public $enableMcp; - private PluginCapabilityGatewayInterface $pluginCapabilityGateway; + /** @var Setting */ + public $rawApiAccessMode; - public function __construct(PluginCapabilityGatewayInterface $pluginCapabilityGateway) + public function __construct(private PluginCapabilityGatewayInterface $pluginCapabilityGateway) { - $this->pluginCapabilityGateway = $pluginCapabilityGateway; - parent::__construct(); } - protected function init(): void { $this->enableMcp = $this->makeSetting( @@ -52,6 +51,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 @@ -59,6 +80,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 a1b26fe..8f29307 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -28,17 +28,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 -> General 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 @@ -62,6 +58,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 1923649..3c052ba 100644 --- a/lang/en.json +++ b/lang/en.json @@ -44,6 +44,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)", - "PlatformMenu": "MCP Server" + "PlatformMenu": "MCP Server", + "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 73a034d..2eb889d 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 20bab8e..dbba33f 100644 --- a/tests/Integration/SystemSettingsTest.php +++ b/tests/Integration/SystemSettingsTest.php @@ -11,7 +11,9 @@ namespace Piwik\Plugins\McpServer\tests\Integration; +use Piwik\Access; use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; +use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\SystemSettings; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -22,12 +24,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 = $this->createSettingsWithOAuth2Enabled(false); + 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 @@ -39,8 +62,40 @@ 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); + } } private function createSettingsWithOAuth2Enabled(bool $oauth2Enabled): SystemSettings diff --git a/tests/UI/McpServer_spec.js b/tests/UI/McpServer_spec.js index 831d57e..8358203 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'; @@ -55,18 +56,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); @@ -93,14 +121,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(); @@ -116,7 +153,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); @@ -135,10 +172,10 @@ describe('McpServer', function () { }); it('should display the connect page when MCP is enabled', async function () { - await setMcpEnabled(true); + await configureMcp(true); 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); const text = await getConnectText(); @@ -153,7 +190,7 @@ describe('McpServer', function () { }); it('should display OAuth2 guidance when the OAuth2 plugin is enabled', async function () { - await setMcpEnabled(true); + await configureMcp(true); testEnvironment.mockOAuth2PluginEnabled = 1; testEnvironment.save(); 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 a7e3e1efbfbd26182f81fa545a4ed4860b2dbd51 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Fri, 13 Mar 2026 18:07:17 +0100 Subject: [PATCH 06/13] 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 2d8dd29..d76893f 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -60,6 +60,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 8f29307..e0f4a92 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -32,8 +32,9 @@ Configure raw Matomo API tool access in **Administration -> System -> General Se - 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 3c052ba..f9c8263 100644 --- a/lang/en.json +++ b/lang/en.json @@ -48,10 +48,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/13] 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/13] 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 d76893f..27f2f78 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -70,6 +70,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 e0f4a92..0abe324 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -32,9 +32,14 @@ Configure raw Matomo API tool access in **Administration -> System -> General Se - 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 @@ -54,8 +59,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 f9c8263..785a04e 100644 --- a/lang/en.json +++ b/lang/en.json @@ -45,14 +45,17 @@ "EnableMcpHelpUrl": "Your MCP URL: %1$s%2$s%3$s", "EnableMcpTitle": "Enable MCP Server (Model Context Protocol)", "PlatformMenu": "MCP Server", - "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 dbba33f..fd9a5f0 100644 --- a/tests/Integration/SystemSettingsTest.php +++ b/tests/Integration/SystemSettingsTest.php @@ -91,6 +91,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 e464315aecc1d0775cc446ed2dfc872f9bf50494 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Wed, 8 Apr 2026 11:02:15 +0200 Subject: [PATCH 09/13] 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 | 43 ++++-- tests/Unit/McpTools/ApiListTest.php | 10 +- .../Api/ApiMethodSummaryQueryServiceTest.php | 15 +- .../Support/Access/RawApiAccessModeTest.php | 35 ++++- .../Support/Access/RawApiMethodPolicyTest.php | 20 ++- 13 files changed, 379 insertions(+), 108 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 27f2f78..f6d4779 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -20,11 +20,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; public function __construct(private PluginCapabilityGatewayInterface $pluginCapabilityGateway) { @@ -52,31 +68,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 @@ -86,7 +145,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 0abe324..ad05442 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -30,13 +30,10 @@ log_tool_call_parameters_full = 0 Configure raw Matomo API tool access in **Administration -> System -> General 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 785a04e..3f340dc 100644 --- a/lang/en.json +++ b/lang/en.json @@ -45,17 +45,18 @@ "EnableMcpHelpUrl": "Your MCP URL: %1$s%2$s%3$s", "EnableMcpTitle": "Enable MCP Server (Model Context Protocol)", "PlatformMenu": "MCP Server", - "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" + "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.", + "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 fd9a5f0..efc48cd 100644 --- a/tests/Integration/SystemSettingsTest.php +++ b/tests/Integration/SystemSettingsTest.php @@ -45,7 +45,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); } @@ -83,30 +83,58 @@ 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', + }); + } + private function createSettingsWithOAuth2Enabled(bool $oauth2Enabled): SystemSettings { $gateway = $this->createMock(PluginCapabilityGatewayInterface::class); diff --git a/tests/UI/McpServer_spec.js b/tests/UI/McpServer_spec.js index 8358203..6353876 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'; @@ -56,7 +56,7 @@ describe('McpServer', function () { await page.waitForNetworkIdle(); } - async function isRawApiAccessModeVisible() + async function isRawApiAccessScopeVisible() { return page.evaluate((selector) => { const element = document.querySelector(selector); @@ -66,10 +66,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); @@ -87,14 +87,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); @@ -125,14 +141,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'); }); 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 b0b37f6342524f5d92c653963e071cb06ecbe447 Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Wed, 8 Apr 2026 22:44:05 +0200 Subject: [PATCH 10/13] 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 +-- 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 +++------------ 16 files changed, 560 insertions(+), 243 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 692e1ae..cc3299a 100644 --- a/McpServerFactory.php +++ b/McpServerFactory.php @@ -16,11 +16,16 @@ 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\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( @@ -165,6 +144,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/docs/faq.md b/docs/faq.md index ad05442..bc6e92b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -30,10 +30,10 @@ log_tool_call_parameters_full = 0 Configure raw Matomo API tool access in **Administration -> System -> General 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 2eb889d..9799d58 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 ca8f4372e20574fb370fa94766383f0ca1a5108d Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Wed, 8 Apr 2026 22:59:09 +0200 Subject: [PATCH 11/13] Update README --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9792c30..33de0f3 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,16 @@ Here are a few examples of what you can do: * Power dashboards with AI-generated insights * Enable teams to explore data without needing analytics expertise +### Go beyond insights: take action with AI (optional) + +If you choose to enable it, the MCP Server can also perform actions in Matomo. This means your AI tools can for example: + +* Create and update segments +* Automate repetitive analytics tasks +* Integrate Matomo into internal workflows + +All actions are controlled by your permissions and configuration. + ### Why install this plugin? * Save time – no more manual report building * Make data accessible – anyone can ask questions, no training needed @@ -55,13 +65,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, including OAuth2 when the Matomo `OAuth2` plugin is installed and enabled and an OAuth2 client is configured for the MCP client, or `token_auth` Bearer tokens otherwise. - 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 7be428c95166bb55bd7b9ed6b5e9a0a504bc761c Mon Sep 17 00:00:00 2001 From: Marc Neudert Date: Thu, 9 Apr 2026 19:50:42 +0200 Subject: [PATCH 12/13] 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 | 23 ++- tests/Unit/APITest.php | 78 +++++++- .../Support/Access/McpAccessLevelTest.php | 44 +++++ 15 files changed, 661 insertions(+), 62 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 33de0f3..e73c6a9 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ 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, including OAuth2 when the Matomo `OAuth2` plugin is installed and enabled and an OAuth2 client is configured for the MCP client, or `token_auth` Bearer tokens otherwise. - Data access is limited to the same sites and reports the Matomo user can already access. +- Data access can be limited to specific permissions/roles and what type of methods can be accessed. +- 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 f6d4779..db3f55f 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -13,6 +13,7 @@ use Piwik\Piwik; use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; +use Piwik\Plugins\McpServer\Support\Access\McpAccessLevel; use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Settings\FieldConfig; use Piwik\Settings\Setting; @@ -27,6 +28,9 @@ class SystemSettings extends \Piwik\Settings\Plugin\SystemSettings /** @var Setting */ public $enableMcp; + /** @var Setting */ + public $maximumMcpAccessLevel; + /** @var Setting */ public $rawApiAccessScope; @@ -68,6 +72,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'), @@ -143,6 +169,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 bc6e92b..314ccb0 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -39,6 +39,13 @@ Configure raw Matomo API tool access in **Administration -> System -> General Se - 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 -> General 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 -> General Settings -> McpServer**. @@ -65,5 +72,5 @@ The plugin is focused on read-oriented analytics workflows. The exact tool surfa ## Troubleshooting - `401 Unauthorized`: verify the Bearer token is present and active. If you use OAuth2, verify the client completed authorization successfully and is sending a valid access token. If you use `token_auth`, verify you are sending `Authorization: Bearer ` and that the token belongs to a user with access to the requested site data. -- `403 Forbidden`: if MCP is disabled, enable MCP in **Administration -> System -> General Settings -> McpServer**. If MCP is already enabled, verify the authenticated Matomo user behind the OAuth2 access token or `token_auth` has access to the requested site or report data. +- `403 Forbidden`: if MCP is disabled, enable MCP in **Administration -> System -> General Settings -> McpServer**. If MCP is already enabled, verify the authenticated Matomo user behind the OAuth2 access token or `token_auth` 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 3f340dc..06988d2 100644 --- a/lang/en.json +++ b/lang/en.json @@ -44,6 +44,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", "PlatformMenu": "MCP Server", "RawApiAccessCreateTitle": "Create methods", "RawApiAccessDeleteTitle": "Delete methods", 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 efc48cd..7b668d4 100644 --- a/tests/Integration/SystemSettingsTest.php +++ b/tests/Integration/SystemSettingsTest.php @@ -13,6 +13,7 @@ use Piwik\Access; use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; +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; @@ -25,6 +26,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 @@ -34,6 +36,7 @@ public function setUp(): void $this->settings = $this->createSettingsWithOAuth2Enabled(false); self::assertInstanceOf(SystemSettings::class, $this->settings); $this->originalEnableMcp = $this->settings->isMcpEnabled(); + $this->originalMaximumAllowedMcpAccessLevel = $this->settings->getMaximumAllowedMcpAccessLevel(); $this->originalRawApiAccessMode = $this->settings->getRawApiAccessMode(); } @@ -45,6 +48,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); @@ -74,6 +78,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 6353876..9329a4a 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'; @@ -69,7 +70,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); @@ -87,11 +93,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(); } @@ -145,9 +163,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'); 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 13/13] 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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