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/Contracts/Ports/Api/ApiCallQueryServiceInterface.php b/Contracts/Ports/Api/ApiCallQueryServiceInterface.php new file mode 100644 index 0000000..5cf09b8 --- /dev/null +++ b/Contracts/Ports/Api/ApiCallQueryServiceInterface.php @@ -0,0 +1,26 @@ +|null $parameters + */ + public function callApi( + ApiMethodSummaryRecord $resolvedMethod, + ?array $parameters = null, + ): ApiCallRecord; +} diff --git a/Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php b/Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php new file mode 100644 index 0000000..d8bc78d --- /dev/null +++ b/Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php @@ -0,0 +1,30 @@ + + */ + public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array; + + public function getApiMethodSummaryBySelector( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ApiMethodSummaryRecord; +} 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/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php b/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php new file mode 100644 index 0000000..9688e67 --- /dev/null +++ b/Contracts/Records/Api/ApiMethodSummaryQueryRecord.php @@ -0,0 +1,40 @@ +, + * operationCategory: string|null, + * } + */ +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, + 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', + ) { + } + + /** + * @return ApiMethodSummaryArray + */ + public function toArray(): array + { + return [ + 'module' => $this->module, + 'action' => $this->action, + 'method' => $this->method, + 'parameters' => $this->parameters, + 'operationCategory' => $this->operationCategory, + ]; + } +} diff --git a/McpServerFactory.php b/McpServerFactory.php index bb49ac5..5599c4c 100644 --- a/McpServerFactory.php +++ b/McpServerFactory.php @@ -14,13 +14,28 @@ 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\Builder; use Matomo\Dependencies\McpServer\Mcp\Server\Handler\Request\CallToolHandler; use Matomo\Dependencies\McpServer\Mcp\Server\Session\SessionStoreInterface; use Piwik\Config; use Piwik\Log\LoggerInterface; use Piwik\Plugin\Manager; +use Piwik\Plugins\McpServer\McpTools\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; +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; 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; @@ -36,6 +51,7 @@ public function __construct( private SessionStoreInterface $sessionStore, private ContainerInterface $container, private ToolCallParameterFormatter $toolCallParameterFormatter, + private SystemSettings $systemSettings, ) { } @@ -66,6 +82,47 @@ public function createServer(): Server completions: false, )); + $rawApiAccessMode = $this->systemSettings->getRawApiAccessMode(); + if (RawApiAccessMode::allowsToolRegistration($rawApiAccessMode)) { + $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( + [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, + "Use when: you need discoverable Matomo API methods and parameter metadata.\n" + . "Purpose: return paginated API method summaries aligned with Matomo API docs visibility.\n" + . "Next: choose a method and map parameters for subsequent raw API tooling.", + new ToolAnnotations( + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + ), + ApiListToolInputSchema::SCHEMA, + null, + null, + ApiMethodSummaryToolOutputSchema::PAGINATED_LIST, + ); + } + if ($loggingConfig['logToolCalls']) { $referenceHandler = new ReferenceHandler($this->container); $callToolHandler = new CallToolHandler($registry, $referenceHandler, new NullLogger()); @@ -81,6 +138,119 @@ public function createServer(): Server return $builder->build(); } + private function registerRawApiCallTools(Builder $builder, string $rawApiAccessMode): void + { + if (RawApiAccessMode::allowsCategory($rawApiAccessMode, RawApiAccessMode::READ)) { + $builder->addTool( + [ApiCallRead::class, 'call'], + ApiCallRead::TOOL_NAME, + "Use when: you need to execute a known read-only Matomo API method directly.\n" + . "Purpose: call one allowed read method and return its result plus the resolved method metadata.\n" + . "Next: use " . ApiGet::TOOL_NAME . ' or ' . ApiList::TOOL_NAME + . ' first if you still need to confirm the method signature.', + new ToolAnnotations( + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + ), + ApiCallToolInputSchema::SCHEMA, + null, + null, + ApiCallToolOutputSchema::ITEM, + ); + } + + if (RawApiAccessMode::allowsCategory($rawApiAccessMode, RawApiAccessMode::CREATE)) { + $builder->addTool( + [ApiCallCreate::class, 'call'], + ApiCallCreate::TOOL_NAME, + "Use when: you need to execute a known create-style Matomo API method directly.\n" + . "Purpose: call one allowed create method and return its result plus the" + . " resolved method metadata.\n" + . "Next: use " . ApiGet::TOOL_NAME . ' or ' . ApiList::TOOL_NAME + . ' first if you still need to confirm the method signature.', + new ToolAnnotations( + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + ), + ApiCallToolInputSchema::SCHEMA, + null, + null, + ApiCallToolOutputSchema::ITEM, + ); + } + + if (RawApiAccessMode::allowsCategory($rawApiAccessMode, RawApiAccessMode::UPDATE)) { + $builder->addTool( + [ApiCallUpdate::class, 'call'], + ApiCallUpdate::TOOL_NAME, + "Use when: you need to execute a known update-style Matomo API method directly.\n" + . "Purpose: call one allowed update method and return its result plus the" + . " resolved method metadata.\n" + . "Next: use " . ApiGet::TOOL_NAME . ' or ' . ApiList::TOOL_NAME + . ' first if you still need to confirm the method signature.', + new ToolAnnotations( + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + ), + ApiCallToolInputSchema::SCHEMA, + null, + null, + ApiCallToolOutputSchema::ITEM, + ); + } + + if (RawApiAccessMode::allowsCategory($rawApiAccessMode, RawApiAccessMode::DELETE)) { + $builder->addTool( + [ApiCallDelete::class, 'call'], + ApiCallDelete::TOOL_NAME, + "Use when: you need to execute a known delete-style Matomo API method directly.\n" + . "Purpose: call one allowed delete method and return its result plus the" + . " resolved method metadata.\n" + . "Next: use " . ApiGet::TOOL_NAME . ' or ' . ApiList::TOOL_NAME + . ' first if you still need to confirm the method signature.', + new ToolAnnotations( + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + ), + ApiCallToolInputSchema::SCHEMA, + null, + null, + ApiCallToolOutputSchema::ITEM, + ); + } + + if ($rawApiAccessMode === RawApiAccessMode::FULL) { + $builder->addTool( + [ApiCallFull::class, 'call'], + ApiCallFull::TOOL_NAME, + "Use when: you need to execute a known Matomo API method directly and" + . " it is not safely covered by one CRUD-specific tool.\n" + . "Purpose: call one allowed full-access API method and return its result" + . " plus the resolved method metadata.\n" + . "Next: prefer CRUD-specific raw API call tools when the method" + . " classification is known.", + new ToolAnnotations( + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + ), + ApiCallToolInputSchema::SCHEMA, + null, + null, + ApiCallToolOutputSchema::ITEM, + ); + } + } + /** * @return array{logToolCalls: bool, logFullParameters: bool, logLevel: string} */ diff --git a/McpTools/AbstractApiCall.php b/McpTools/AbstractApiCall.php new file mode 100644 index 0000000..fa7fea9 --- /dev/null +++ b/McpTools/AbstractApiCall.php @@ -0,0 +1,75 @@ +|null $parameters + * @return ApiCallArray + */ + public function call( + ?string $method = null, + ?string $module = null, + ?string $action = null, + ?array $parameters = null, + ): array { + $parameters = $parameters === null + ? null + : ToolDataNormalizer::requireStringKeyedArrayOrEmptyList($parameters, 'parameters'); + + $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 @@ +queryService->getApiMethodSummaryBySelector( + $this->systemSettings->getRawApiAccessMode(), + $method, + $module, + $action, + )->toArray(); + } +} diff --git a/McpTools/ApiList.php b/McpTools/ApiList.php new file mode 100644 index 0000000..801ef8f --- /dev/null +++ b/McpTools/ApiList.php @@ -0,0 +1,85 @@ +, + * 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, + ?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, + ]); + + $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/README.md b/README.md index 20795ce..b8331f0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ McpServer is an early preview plugin that adds a secure Model Context Protocol (MCP) endpoint to Matomo so AI assistants and MCP-compatible clients can work with analytics context directly from your Matomo instance. -It provides read-oriented tools for sites, reports, processed report data, goals, segments, and dimensions using the same Matomo authentication and access rules you already use in Matomo. +It provides analytics tools for sites, reports, processed report data, goals, segments, and dimensions using the same Matomo authentication and access rules you already use in Matomo. Administrators can also optionally enable raw Matomo API discovery and direct API execution tools for advanced MCP workflows. ### Setup @@ -12,19 +12,24 @@ It provides read-oriented tools for sites, reports, processed report data, goals 2. Activate **McpServer** in **Administration -> Plugins**. 3. Enable MCP in **Administration -> System -> Plugin Settings -> McpServer**. 4. Configure your MCP client with the endpoint and a Matomo `token_auth` that already has access to the data you want to expose. +5. If needed, restrict the maximum allowed MCP privilege level in plugin settings or use a separate lower-privilege Matomo user for MCP access. For the recommended end-user setup flow, use the in-product connect guide at **Administration -> Platform -> MCP Server**. ### Security And Access Model - MCP access is disabled by default. +- Raw Matomo API discovery and execution tools are separately disabled by default and must be enabled by an administrator. - The plugin uses Matomo authentication. - Data access is limited to the same sites and reports the Matomo user can already access. +- 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. ### 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 diff --git a/Schemas/Api/ApiCallToolInputSchema.php b/Schemas/Api/ApiCallToolInputSchema.php new file mode 100644 index 0000000..e216989 --- /dev/null +++ b/Schemas/Api/ApiCallToolInputSchema.php @@ -0,0 +1,74 @@ + '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' => [ + 'oneOf' => [ + [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'Optional Matomo API parameters for the selected method.', + ], + [ + 'type' => 'array', + 'maxItems' => 0, + 'description' => 'Empty array is accepted and normalized as no parameters.', + ], + ], + ], + ], + 'not' => [ + '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/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/Schemas/Api/ApiListToolInputSchema.php b/Schemas/Api/ApiListToolInputSchema.php new file mode 100644 index 0000000..8d99528 --- /dev/null +++ b/Schemas/Api/ApiListToolInputSchema.php @@ -0,0 +1,67 @@ + '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.', + ], + '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 new file mode 100644 index 0000000..f591d72 --- /dev/null +++ b/Schemas/Api/ApiMethodSummaryToolOutputSchema.php @@ -0,0 +1,77 @@ + '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, + ], + 'operationCategory' => [ + 'type' => ['string', 'null'], + 'enum' => [ + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CATEGORY_UPDATE, + ApiMethodOperationClassifier::CATEGORY_DELETE, + null, + ], + ], + ], + 'required' => [ + 'module', + 'action', + 'method', + 'parameters', + 'operationCategory', + ], + '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/ApiCallQueryService.php b/Services/Api/ApiCallQueryService.php new file mode 100644 index 0000000..85a0d7c --- /dev/null +++ b/Services/Api/ApiCallQueryService.php @@ -0,0 +1,212 @@ + */ + 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 CoreApiCallGatewayInterface $coreApiCallGateway) + { + } + + public function callApi( + ApiMethodSummaryRecord $resolvedMethod, + ?array $parameters = null, + ): ApiCallRecord { + $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/ApiMethodSummaryQueryService.php b/Services/Api/ApiMethodSummaryQueryService.php new file mode 100644 index 0000000..06f7fc8 --- /dev/null +++ b/Services/Api/ApiMethodSummaryQueryService.php @@ -0,0 +1,349 @@ + + */ + 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. + * + * @return array + */ + 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(); + $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( + $className, + $action, + $methodInfo, + $isDeprecated, + ); + if (!$shouldInclude) { + continue; + } + /** @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'], + ); + } + } + + 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->filterByAccessPolicy($records, $query->accessMode); + $records = $this->filterByModule($records, $query->module); + $records = $this->filterBySearch($records, $query->search); + $records = $this->filterByOperationCategory($records, $query->operationCategory); + + 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. + * + * @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 $className, + mixed $action, + mixed $methodInfo, + bool $isDeprecated, + ): bool { + if (!is_string($action) || $action === '__documentation') { + return false; + } + + if ($isDeprecated) { + return false; + } + + 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 filterByAccessPolicy(array $records, string $accessMode): array + { + return array_values(array_filter( + $records, + static fn(ApiMethodSummaryRecord $record): bool => RawApiMethodPolicy::allowsMethod( + $accessMode, + $record->method, + $record->action, + $record->operationCategory, + $record->classificationConfidence, + ) + )); + } + + /** + * @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) + )); + } + + /** + * @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)); + } + + private function hasInternalAnnotation(string|false $docComment): bool + { + return is_string($docComment) && str_contains($docComment, '@internal'); + } +} diff --git a/Services/Api/CoreApiCallGateway.php b/Services/Api/CoreApiCallGateway.php new file mode 100644 index 0000000..772ec61 --- /dev/null +++ b/Services/Api/CoreApiCallGateway.php @@ -0,0 +1,46 @@ +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 (NoAccessLikeErrorDetector::isDetected($e)) { + throw new AccessDeniedLikeException('No access to API method.', 0, $e); + } + + throw new CoreApiRequestException('Matomo API request failed.', 0, $e); + } + } +} 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 478764b..aceb9d2 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; @@ -563,7 +564,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) { @@ -573,7 +574,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() ); } @@ -785,23 +786,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 e69596a..d4fdb0b 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 []; @@ -125,16 +126,4 @@ private function isSubtableReport(array $report): bool $alias = $report['isSubtableReports'] ?? null; return $alias === true || $alias === 1 || $alias === '1'; } - - 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/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/Access/RawApiAccessMode.php b/Support/Access/RawApiAccessMode.php new file mode 100644 index 0000000..26b427e --- /dev/null +++ b/Support/Access/RawApiAccessMode.php @@ -0,0 +1,117 @@ + */ + private const CRUD_MODES = [ + self::READ, + self::CREATE, + self::UPDATE, + self::DELETE, + ]; + + public static function normalize(mixed $configuredMode): string + { + if (is_array($configuredMode)) { + $tokens = $configuredMode; + } elseif (is_scalar($configuredMode)) { + $tokens = preg_split('/[\s,]+/', strtolower(trim((string) $configuredMode))) ?: []; + } else { + return self::DEFAULT; + } + + $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; + } + + $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 self::normalize($mode) !== self::NONE; + } + + public static function allowsCategory(string $mode, ?string $category): bool + { + $mode = self::normalize($mode); + if ($mode === self::FULL) { + return true; + } + + $normalizedCategory = ApiMethodOperationClassifier::normalizeCategory($category); + if ($normalizedCategory === '') { + return false; + } + + return in_array($normalizedCategory, explode(',', $mode), true); + } +} diff --git a/Support/Access/RawApiMethodPolicy.php b/Support/Access/RawApiMethodPolicy.php new file mode 100644 index 0000000..f213947 --- /dev/null +++ b/Support/Access/RawApiMethodPolicy.php @@ -0,0 +1,81 @@ + */ + 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, + ?string $operationCategory = null, + ?string $classificationConfidence = null, + ): bool { + if (self::isDeniedMethod($method)) { + return false; + } + + if ($accessMode === RawApiAccessMode::FULL) { + return true; + } + + if ($accessMode === RawApiAccessMode::NONE) { + return false; + } + + if (self::normalizeConfidence($classificationConfidence) === ApiMethodOperationClassifier::CONFIDENCE_LOW) { + return false; + } + + return RawApiAccessMode::allowsCategory($accessMode, $operationCategory); + } + + public static function isDeniedMethod(string $method): bool + { + return isset(self::DENIED_METHODS[self::normalizeSelectorValue($method)]); + } + + private static function normalizeConfidence(?string $confidence): string + { + $normalizedConfidence = self::normalizeSelectorValue($confidence); + + if ( + $normalizedConfidence !== ApiMethodOperationClassifier::CONFIDENCE_HIGH + && $normalizedConfidence !== ApiMethodOperationClassifier::CONFIDENCE_MEDIUM + ) { + return ApiMethodOperationClassifier::CONFIDENCE_LOW; + } + + return $normalizedConfidence; + } + + private static 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/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/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/Support/Pagination/ApiMethodsPagination.php b/Support/Pagination/ApiMethodsPagination.php new file mode 100644 index 0000000..6e7b44e --- /dev/null +++ b/Support/Pagination/ApiMethodsPagination.php @@ -0,0 +1,52 @@ +enableMcp = $this->makeSetting( @@ -42,6 +66,96 @@ function (FieldConfig $field) { $field->uiControl = FieldConfig::UI_CONTROL_CHECKBOX; }, ); + + $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->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'), + 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) 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 = [ + 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 @@ -49,6 +163,49 @@ 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()); + 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 { return $this->getNormalizedBaseUrl() . '/index.php?module=API&method=McpServer.mcp&format=mcp'; diff --git a/config/config.php b/config/config.php index c3d8bb1..00a4c02 100644 --- a/config/config.php +++ b/config/config.php @@ -6,6 +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,6 +30,16 @@ use Piwik\Plugins\McpServer\Contracts\Ports\Sites\SiteSummaryQueryServiceInterface; use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface; use Piwik\Plugins\McpServer\McpServerFactory; +use Piwik\Plugins\McpServer\McpTools\ApiCallCreate; +use Piwik\Plugins\McpServer\McpTools\ApiCallDelete; +use Piwik\Plugins\McpServer\McpTools\ApiCallFull; +use Piwik\Plugins\McpServer\McpTools\ApiCallRead; +use Piwik\Plugins\McpServer\McpTools\ApiCallUpdate; +use Piwik\Plugins\McpServer\McpTools\ApiGet; +use Piwik\Plugins\McpServer\McpTools\ApiList; +use Piwik\Plugins\McpServer\Services\Api\ApiCallQueryService; +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; @@ -52,6 +65,9 @@ use Piwik\Plugins\McpServer\Tasks; return [ + ApiCallQueryServiceInterface::class => DI::autowire(ApiCallQueryService::class), + ApiMethodSummaryQueryServiceInterface::class => DI::autowire(ApiMethodSummaryQueryService::class), + CoreApiCallGatewayInterface::class => DI::autowire(CoreApiCallGateway::class), CoreApiModuleGatewayInterface::class => DI::autowire(CoreApiModuleGateway::class), CoreCustomDimensionsGatewayInterface::class => DI::autowire(CoreCustomDimensionsGateway::class), CoreGoalsGatewayInterface::class => DI::autowire(CoreGoalsGateway::class), @@ -77,5 +93,12 @@ DbSessionStore::class => DI::autowire(), McpServerFactory::class => DI::autowire(), PaginatedCollectionResponder::class => DI::autowire(), + ApiCallCreate::class => DI::autowire(), + ApiCallDelete::class => DI::autowire(), + ApiCallFull::class => DI::autowire(), + ApiCallRead::class => DI::autowire(), + ApiCallUpdate::class => DI::autowire(), + ApiGet::class => DI::autowire(), + ApiList::class => DI::autowire(), Tasks::class => DI::autowire(), ]; diff --git a/docs/faq.md b/docs/faq.md index a6b5c94..141a1f2 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -27,6 +27,24 @@ 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 **Administration -> System -> Plugin Settings -> McpServer**: + +- Use the **Raw Matomo API tool access** drop-down to control visibility for `matomo_api_list`, `matomo_api_get`, and 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`. +- Direct API access can expose raw or personal data depending on enabled Matomo features. Review privacy and security requirements before enabling it, and consult your DPO or compliance owner when needed. + +Configure MCP privilege limits in **Administration -> System -> Plugin Settings -> McpServer**: + +- Use **Maximum allowed MCP privilege level** to deny MCP access for users authenticated with a higher Matomo privilege. +- `No privilege limit` (default): follows the usual Matomo access model and does not add an extra MCP privilege cap. +- `View access`, `Write access`, or `Admin access`: allows only users whose highest privilege across all sites is at or below the selected level. +- For stricter separation, create a separate Matomo user or token with reduced permissions for MCP use. + ## Enabling MCP MCP access is disabled by default and must be enabled in **Administration -> System -> Plugin Settings -> McpServer**. @@ -42,14 +60,14 @@ 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 +- raw Matomo API discovery and execution, when enabled by an administrator ## Troubleshooting - `401 Unauthorized`: verify the Bearer token is present, active, and belongs to a user with access to the requested site data. -- `403 Forbidden`: if MCP is disabled, enable MCP in **Administration -> System -> Plugin Settings -> McpServer**. If MCP is already enabled, verify the authenticated Matomo user has access to the requested site or report data. +- `403 Forbidden`: if MCP is disabled, enable MCP in **Administration -> System -> Plugin Settings -> McpServer**. If MCP is already enabled, verify the authenticated Matomo user has access to the requested site or report data and does not exceed the configured maximum MCP privilege level. - `400 Bad Request`: verify the client is using the exact MCP endpoint and is not proxying requests through `API.getBulkRequest`. diff --git a/lang/en.json b/lang/en.json index 8a5e9a0..9377409 100644 --- a/lang/en.json +++ b/lang/en.json @@ -31,6 +31,27 @@ "EnableMcpHelpPolicy": "Before enabling, ensure this complies with your organization's privacy policies and applicable regulations. You may need approval from your data protection officer (DPO) or to update your privacy policy.", "EnableMcpHelpPurpose": "Enable the Matomo MCP Server (Model Context Protocol) to allow AI tools and assistants to access analytics context from your Matomo instance.", "EnableMcpHelpUrl": "Your MCP URL: %1$s%2$s%3$s", - "EnableMcpTitle": "Enable MCP Server (Model Context Protocol)" + "EnableMcpTitle": "Enable MCP Server (Model Context Protocol)", + "MaximumMcpAccessLevelAdmin": "Admin access", + "MaximumMcpAccessLevelHelpPurpose": "Choose the highest Matomo privilege level allowed to use the MCP endpoint. Users authenticated with a higher privilege level will be denied.", + "MaximumMcpAccessLevelHelpSeparateUser": "If you need tighter isolation, create a separate Matomo user for MCP with only the required site permissions.", + "MaximumMcpAccessLevelHelpTokens": "Use this setting to limit MCP access to lower-privilege users or tokens.", + "MaximumMcpAccessLevelTitle": "Maximum allowed MCP privilege level", + "MaximumMcpAccessLevelUnlimited": "No privilege limit", + "MaximumMcpAccessLevelView": "View access", + "MaximumMcpAccessLevelWrite": "Write access", + "RawApiAccessHelpDataScope": "Direct Matomo API access can expose the same data available through the Matomo user interface or direct API endpoints, including raw or personal data when features such as the Visitor Log are enabled.", + "RawApiAccessHelpDestructive": "Partial API access can enable create, update, and delete methods through the selected checkboxes below. Full API access can execute any allowed state-changing or destructive API methods, including actions that modify configuration or delete data.", + "RawApiAccessHelpPolicy": "Before enabling direct API access, ensure this complies with your organization's privacy and security policies and applicable regulations. You may need approval from your data protection officer (DPO) or another compliance owner.", + "RawApiAccessHelpPurpose": "Choose whether the direct raw Matomo API tools are hidden, exposed for partial API access, or exposed with full API access. When Partial API access is selected, use the checkboxes below to choose which CRUD categories are available.", + "RawApiAccessHelpReadFallback": "Direct API access uses CRUD-style classification for discovered API methods. In Partial API access mode, only the checked CRUD categories are available. Dedicated report tools remain available independently, permanently restricted APIs stay blocked in every mode, and low-confidence methods require Full API access.", + "RawApiAccessCreateTitle": "Create methods", + "RawApiAccessDeleteTitle": "Delete methods", + "RawApiAccessReadTitle": "Read methods", + "RawApiAccessScopeFull": "Full API access", + "RawApiAccessScopeNone": "No API access", + "RawApiAccessScopePartial": "Partial API access", + "RawApiAccessScopeTitle": "Raw Matomo API tool access", + "RawApiAccessUpdateTitle": "Update methods" } } diff --git a/tests/Framework/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 ad3fe0e..a31d2de 100644 --- a/tests/Framework/McpTestHelper.php +++ b/tests/Framework/McpTestHelper.php @@ -35,6 +35,9 @@ 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; /** * @phpstan-import-type ToolData from Tool @@ -69,6 +72,62 @@ 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 { + $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); + } + } + + 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 623511a..71fc1ba 100644 --- a/tests/Integration/McpServerTest.php +++ b/tests/Integration/McpServerTest.php @@ -15,6 +15,8 @@ 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; use Piwik\Plugins\McpServer\tests\Framework\McpTestHelper; @@ -130,6 +132,8 @@ 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); @@ -139,9 +143,58 @@ 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()); + + $this->applyRawApiAccessMode($systemSettings, RawApiAccessMode::CREATE); + self::assertSame('create', $systemSettings->getRawApiAccessMode()); + + $this->applyRawApiAccessMode($systemSettings, RawApiAccessMode::UPDATE); + self::assertSame('update', $systemSettings->getRawApiAccessMode()); + + $this->applyRawApiAccessMode($systemSettings, RawApiAccessMode::DELETE); + self::assertSame('delete', $systemSettings->getRawApiAccessMode()); + + $this->applyRawApiAccessMode($systemSettings, RawApiAccessMode::FULL); + self::assertSame('full', $systemSettings->getRawApiAccessMode()); } finally { $systemSettings->enableMcp->setValue($originalEnableMcpValue); + $systemSettings->maximumMcpAccessLevel->setValue($originalMaximumAllowedMcpAccessLevel); + $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/McpTools/ApiCallTest.php b/tests/Integration/McpTools/ApiCallTest.php new file mode 100644 index 0000000..c53991f --- /dev/null +++ b/tests/Integration/McpTools/ApiCallTest.php @@ -0,0 +1,604 @@ +createSuperUser = true; + } + + public function setUp(): void + { + parent::setUp(); + + $this->originalRawApiAccessMode = McpTestHelper::getRawApiAccessMode(); + $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 tearDown(): void + { + McpTestHelper::setRawApiAccessMode($this->originalRawApiAccessMode); + + parent::tearDown(); + } + + public function testReadModeCallsKnownReadMethodByMethodSelector(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCallRead::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::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']); + } + + public function testReadModeCallsKnownReadMethodByModuleAndActionSelector(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCallRead::TOOL_NAME, + ['module' => ' API ', 'action' => ' getMatomoVersion '], + __METHOD__, + ); + + $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']); + } + + public function testReadModeAcceptsEmptyParametersList(): void + { + McpTestHelper::setRawApiAccessMode('read'); + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCallRead::TOOL_NAME, + ['method' => 'API.getMatomoVersion', 'parameters' => []], + __METHOD__, + ); + + $resolvedMethod = $content['resolvedMethod'] ?? null; + self::assertIsArray($resolvedMethod); + self::assertSame('API.getMatomoVersion', $resolvedMethod['method'] ?? null); + self::assertIsString($content['result'] ?? null); + } + + public function testReadModeAcceptsEmptyParametersObject(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = json_encode([ + 'jsonrpc' => '2.0', + 'id' => __METHOD__, + 'method' => 'tools/call', + 'params' => [ + 'name' => ApiCallRead::TOOL_NAME, + 'arguments' => [ + 'method' => 'API.getMatomoVersion', + 'parameters' => new \stdClass(), + ], + ], + ], JSON_THROW_ON_ERROR); + + $response = McpTestHelper::postJson($server, $payload, ['Mcp-Session-Id' => $sessionId]); + $message = McpTestHelper::decodeResponse($response); + $result = McpTestHelper::parseCallTool($message); + $content = McpTestHelper::assertToolSuccess($result); + $resolvedMethod = $content['resolvedMethod'] ?? null; + self::assertIsArray($resolvedMethod); + self::assertSame('API.getMatomoVersion', $resolvedMethod['method'] ?? null); + self::assertIsString($content['result'] ?? null); + } + + public function testReadModeNormalizesDataTableResponse(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $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, + ApiCallRead::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 + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCallRead::TOOL_NAME, + ['method' => 'UsersManager.addUser'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testReadModeCallsMediumConfidenceReadMethod(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCallRead::TOOL_NAME, + ['method' => 'UsersManager.hasSuperUserAccess'], + __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 testFullModeCallsKnownMediumConfidenceReadMethod(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCallFull::TOOL_NAME, + ['method' => 'UsersManager.hasSuperUserAccess'], + __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); + self::assertIsBool($content['result'] ?? null); + } + + public function testReadModeRejectsBlockedProxyLikeMethod(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCallRead::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, + ApiCallRead::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, + ApiCallRead::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, + ApiCallFull::TOOL_NAME, + ['module' => 'Insights', 'action' => 'getInsights'], + 'API method not found or unavailable.', + __METHOD__, + ); + } + + public function testFullModeAttemptsMutatingMethodCall(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $result = McpTestHelper::callTool( + $server, + $sessionId, + ApiCallFull::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 testCreateModeAttemptsCreateMethodCall(): void + { + McpTestHelper::setRawApiAccessMode('create'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $result = McpTestHelper::callTool( + $server, + $sessionId, + ApiCallCreate::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, + ApiCallDelete::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 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'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + ApiCallRead::TOOL_NAME, + [ + 'method' => 'API.getMatomoVersion', + 'parameters' => ['format' => 'json'], + ], + "Unsupported parameters key 'format'.", + __METHOD__, + ); + } + + public function testRejectsNonEmptyParametersListAtSchemaLevel(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $message = McpTestHelper::callToolExpectInvalidParams( + $server, + $sessionId, + ApiCallRead::TOOL_NAME, + [ + 'method' => 'API.getMatomoVersion', + 'parameters' => ['flat'], + ], + __METHOD__, + ); + + self::assertStringContainsString( + "Invalid parameters for tool '" . ApiCallRead::TOOL_NAME . "':", + $message->message, + ); + } + + public function testRejectsMissingSelectorAtSchemaLevel(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $message = McpTestHelper::callToolExpectInvalidParams( + $server, + $sessionId, + ApiCallFull::TOOL_NAME, + [], + __METHOD__, + ); + + self::assertStringContainsString( + "Invalid parameters for tool '" . ApiCallFull::TOOL_NAME . "':", + $message->message, + ); + } + + public function testRejectsMixedSelectorStyleAtSchemaLevel(): void + { + McpTestHelper::setRawApiAccessMode('full'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeCallToolRequest( + ApiCallFull::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 '" . ApiCallFull::TOOL_NAME . "':", + $message->message ?? '', + ); + } + + public function testSchemaDeclaresFlatSelectorsWithoutTopLevelCombinators(): void + { + McpTestHelper::setRawApiAccessMode('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 === ApiCallFull::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 + { + McpTestHelper::setRawApiAccessMode('none'); + self::assertNotContains(ApiCallRead::TOOL_NAME, $this->listToolNamesForCurrentConfig()); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $payload = McpTestHelper::makeCallToolRequest( + ApiCallRead::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); + } + + 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 + */ + 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/ApiGetTest.php b/tests/Integration/McpTools/ApiGetTest.php new file mode 100644 index 0000000..f128235 --- /dev/null +++ b/tests/Integration/McpTools/ApiGetTest.php @@ -0,0 +1,391 @@ +originalRawApiAccessMode = McpTestHelper::getRawApiAccessMode(); + } + + public function tearDown(): void + { + McpTestHelper::setRawApiAccessMode($this->originalRawApiAccessMode); + + parent::tearDown(); + } + + public function testReadModeReturnsKnownReadMethodByMethodSelector(): void + { + McpTestHelper::setRawApiAccessMode('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); + self::assertSame('read', $content['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $content); + self::assertArrayNotHasKey('classificationReason', $content); + } + + public function testFullModeReturnsKnownMutatingMethodByModuleAndActionSelectors(): void + { + McpTestHelper::setRawApiAccessMode('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); + self::assertSame('create', $content['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $content); + self::assertArrayNotHasKey('classificationReason', $content); + } + + public function testReadModeRejectsWriteOnlyMethod(): void + { + McpTestHelper::setRawApiAccessMode('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 testReadModeAllowsMediumConfidenceReadMethod(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $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); + self::assertSame('read', $content['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $content); + self::assertArrayNotHasKey('classificationReason', $content); + } + + public function testFullModeReturnsKnownMediumConfidenceReadMethod(): 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); + self::assertSame('read', $content['operationCategory'] ?? null); + self::assertArrayNotHasKey('classificationConfidence', $content); + self::assertArrayNotHasKey('classificationReason', $content); + } + + 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 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'); + + $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 + { + McpTestHelper::setRawApiAccessMode('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 + { + McpTestHelper::setRawApiAccessMode('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 + { + McpTestHelper::setRawApiAccessMode('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 + { + McpTestHelper::setRawApiAccessMode('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/ApiListTest.php b/tests/Integration/McpTools/ApiListTest.php new file mode 100644 index 0000000..8884e20 --- /dev/null +++ b/tests/Integration/McpTools/ApiListTest.php @@ -0,0 +1,596 @@ +originalRawApiAccessMode = McpTestHelper::getRawApiAccessMode(); + } + + public function tearDown(): void + { + McpTestHelper::setRawApiAccessMode($this->originalRawApiAccessMode); + + parent::tearDown(); + } + + public function testReadModeExposesOnlyReadClassifiedMethods(): void + { + McpTestHelper::setRawApiAccessMode('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']); + + $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']); + 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 + { + McpTestHelper::setRawApiAccessMode('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 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 testReadModeAllowsMediumConfidenceReadMethods(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $methods = $this->listMethodsForCurrentConfig(500); + + self::assertContains('UsersManager.getUsers', $methods); + self::assertContains('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 testUpdateModeCanReturnUpdateActions(): void + { + McpTestHelper::setRawApiAccessMode('update'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + 'matomo_api_list', + ['search' => 'setdefaulttimezone', 'limit' => 50], + __METHOD__, + ); + $methodsData = $content['methods'] ?? null; + self::assertIsArray($methodsData); + + $methods = array_map( + static fn(array $row): string => (string) ($row['method'] ?? ''), + $methodsData, + ); + + 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 + { + 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'); + + $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 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'); + + $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 + { + McpTestHelper::setRawApiAccessMode('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 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'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + McpTestHelper::callToolAndAssertError( + $server, + $sessionId, + 'matomo_api_list', + ['cursor' => 'invalid'], + 'Invalid cursor.', + __METHOD__, + ); + } + + public function testRejectsCursorSortMismatch(): void + { + McpTestHelper::setRawApiAccessMode('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 + { + 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, '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 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'); + 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)); + } + + /** + * @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/Integration/McpTools/ReportMetadataTest.php b/tests/Integration/McpTools/ReportMetadataTest.php index 30affb4..8ba7a25 100644 --- a/tests/Integration/McpTools/ReportMetadataTest.php +++ b/tests/Integration/McpTools/ReportMetadataTest.php @@ -503,6 +503,10 @@ public function testSchemaDeclaresSelectorRulesWithoutTopLevelCombinators(): voi self::assertArrayNotHasKey('anyOf', $inputSchema); self::assertArrayHasKey('not', $inputSchema); self::assertIsArray($inputSchema['not']); + + $notSchema = $inputSchema['not']; + self::assertArrayHasKey('anyOf', $notSchema); + self::assertIsArray($notSchema['anyOf']); } /** diff --git a/tests/Integration/McpTools/ReportProcessedTest.php b/tests/Integration/McpTools/ReportProcessedTest.php index 248a6af..c118efd 100644 --- a/tests/Integration/McpTools/ReportProcessedTest.php +++ b/tests/Integration/McpTools/ReportProcessedTest.php @@ -490,6 +490,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 ab352fb..a9fac8a 100644 --- a/tests/Integration/McpToolsContractBaselineTest.php +++ b/tests/Integration/McpToolsContractBaselineTest.php @@ -15,6 +15,8 @@ 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\ApiCallRead; +use Piwik\Plugins\McpServer\McpTools\ApiGet; use Piwik\Plugins\McpServer\McpTools\DimensionGet; use Piwik\Plugins\McpServer\McpTools\DimensionList; use Piwik\Plugins\McpServer\McpTools\GoalGet; @@ -27,6 +29,8 @@ 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; use Piwik\Plugins\McpServer\Schemas\Goals\GoalDetailToolOutputSchema; @@ -55,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 { @@ -67,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', @@ -123,6 +130,13 @@ public function setUp(): void $this->reportUniqueId = $reportUniqueId; } + public function tearDown(): void + { + McpTestHelper::setRawApiAccessMode($this->originalRawApiAccessMode); + + parent::tearDown(); + } + /** * @dataProvider provideSuccessCases * @@ -235,6 +249,58 @@ public function testReportListSerializesEmptyParametersAsObjectInBaselineRespons self::assertStringContainsString('"parameters":{}', $body); } + public function testApiListSuccessShapeInReadMode(): void + { + McpTestHelper::setRawApiAccessMode('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'] ?? []); + } + + public function testApiGetSuccessShapeInReadMode(): void + { + McpTestHelper::setRawApiAccessMode('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); + } + + public function testApiCallSuccessShapeInReadMode(): void + { + McpTestHelper::setRawApiAccessMode('read'); + + $server = McpTestHelper::buildServer(); + $sessionId = McpTestHelper::initializeSession($server); + $content = McpTestHelper::callToolAndAssertSuccess( + $server, + $sessionId, + ApiCallRead::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 5a29088..08451cf 100644 --- a/tests/Integration/McpToolsContractTest.php +++ b/tests/Integration/McpToolsContractTest.php @@ -11,6 +11,12 @@ namespace Piwik\Plugins\McpServer\tests\Integration; +use Matomo\Dependencies\McpServer\Mcp\Schema\Tool; +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; @@ -20,8 +26,26 @@ */ 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 { + McpTestHelper::setRawApiAccessMode('none'); + $server = McpTestHelper::buildServer(); $sessionId = McpTestHelper::initializeSession($server); $payload = McpTestHelper::makeListToolsRequest('list-1'); @@ -138,4 +162,167 @@ public function testToolsListContainsAllPluginTools(): void self::assertSame($expectedHints['openWorldHint'], $tool->annotations->openWorldHint); } } + + public function testRawApiListToolIsHiddenWhenRawAccessModeIsNone(): void + { + McpTestHelper::setRawApiAccessMode('none'); + $toolsByName = $this->listToolsByNameForCurrentConfig(); + + 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); + } + + public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessModeIsRead(): void + { + McpTestHelper::setRawApiAccessMode('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); + self::assertTrue($tool->annotations->readOnlyHint); + self::assertFalse($tool->annotations->destructiveHint); + self::assertTrue($tool->annotations->idempotentHint); + self::assertFalse($tool->annotations->openWorldHint); + + self::assertArrayHasKey(ApiCallRead::TOOL_NAME, $toolsByName); + self::assertArrayNotHasKey(ApiCallFull::TOOL_NAME, $toolsByName); + $callTool = $toolsByName[ApiCallRead::TOOL_NAME]; + self::assertNotNull($callTool->annotations); + self::assertTrue($callTool->annotations->readOnlyHint); + self::assertFalse($callTool->annotations->destructiveHint); + self::assertTrue($callTool->annotations->idempotentHint); + 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(ApiCallCreate::TOOL_NAME, $toolsByName); + self::assertArrayNotHasKey(ApiCallFull::TOOL_NAME, $toolsByName); + + $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); + } + + 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(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); + } + + public function testRawApiListToolIsVisibleWithExpectedAnnotationsWhenRawAccessModeIsFull(): void + { + McpTestHelper::setRawApiAccessMode('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); + self::assertTrue($tool->annotations->readOnlyHint); + self::assertFalse($tool->annotations->destructiveHint); + self::assertTrue($tool->annotations->idempotentHint); + self::assertFalse($tool->annotations->openWorldHint); + + 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); + self::assertFalse($callTool->annotations->idempotentHint); + self::assertFalse($callTool->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/Integration/SystemSettingsTest.php b/tests/Integration/SystemSettingsTest.php index d7a190b..264f56a 100644 --- a/tests/Integration/SystemSettingsTest.php +++ b/tests/Integration/SystemSettingsTest.php @@ -11,6 +11,9 @@ namespace Piwik\Plugins\McpServer\tests\Integration; +use Piwik\Access; +use Piwik\Plugins\McpServer\Support\Access\McpAccessLevel; +use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode; use Piwik\Plugins\McpServer\SystemSettings; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -21,12 +24,36 @@ 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 { parent::setUp(); $this->settings = new SystemSettings(); + self::assertInstanceOf(SystemSettings::class, $this->settings); + $this->originalEnableMcp = $this->settings->isMcpEnabled(); + $this->originalMaximumAllowedMcpAccessLevel = $this->settings->getMaximumAllowedMcpAccessLevel(); + $this->originalRawApiAccessMode = $this->settings->getRawApiAccessMode(); + } + + 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->maximumMcpAccessLevel->setValue($this->originalMaximumAllowedMcpAccessLevel); + $this->applyRawApiAccessMode($this->originalRawApiAccessMode); + } finally { + Access::getInstance()->setSuperUserAccess($hadSuperUserAccess); + } + + parent::tearDown(); } public function testMcpIsDisabledByDefault(): void @@ -38,7 +65,103 @@ 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 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); + self::assertSame(RawApiAccessMode::NONE, $this->settings->getRawApiAccessMode()); + } + + public function testCanChangeRawApiAccessMode(): void + { + self::assertInstanceOf(SystemSettings::class, $this->settings); + $settings = $this->settings; + $access = Access::getInstance(); + $hadSuperUserAccess = $access->hasSuperUserAccess(); + $access->setSuperUserAccess(true); + + try { + $this->applyRawApiAccessMode(RawApiAccessMode::READ); + self::assertSame(RawApiAccessMode::READ, $settings->getRawApiAccessMode()); + + $this->applyRawApiAccessMode(RawApiAccessMode::CREATE); + self::assertSame(RawApiAccessMode::CREATE, $settings->getRawApiAccessMode()); + + $this->applyRawApiAccessMode(RawApiAccessMode::UPDATE); + self::assertSame(RawApiAccessMode::UPDATE, $settings->getRawApiAccessMode()); + + $this->applyRawApiAccessMode(RawApiAccessMode::DELETE); + self::assertSame(RawApiAccessMode::DELETE, $settings->getRawApiAccessMode()); + + $this->applyRawApiAccessMode(RawApiAccessMode::FULL); + self::assertSame(RawApiAccessMode::FULL, $settings->getRawApiAccessMode()); + + $this->applyRawApiAccessMode(RawApiAccessMode::READ . ',' . RawApiAccessMode::UPDATE); + self::assertSame(RawApiAccessMode::READ . ',' . RawApiAccessMode::UPDATE, $settings->getRawApiAccessMode()); + } finally { + $access->setSuperUserAccess($hadSuperUserAccess); + } + } + + private function applyRawApiAccessMode(string $mode): void + { + self::assertInstanceOf(SystemSettings::class, $this->settings); + $normalizedMode = RawApiAccessMode::normalize($mode); + + $this->settings->rawApiAccessRead->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::READ), + ); + $this->settings->rawApiAccessCreate->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::CREATE), + ); + $this->settings->rawApiAccessUpdate->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::UPDATE), + ); + $this->settings->rawApiAccessDelete->setValue( + RawApiAccessMode::allowsCategory($normalizedMode, RawApiAccessMode::DELETE), + ); + $this->settings->rawApiAccessScope->setValue(match ($normalizedMode) { + RawApiAccessMode::FULL => 'full', + RawApiAccessMode::NONE => 'none', + default => 'partial', + }); } } diff --git a/tests/UI/McpServer_spec.js b/tests/UI/McpServer_spec.js index d41ceff..3375f20 100644 --- a/tests/UI/McpServer_spec.js +++ b/tests/UI/McpServer_spec.js @@ -14,6 +14,8 @@ 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'; @@ -54,18 +56,78 @@ describe('McpServer', function () { await page.waitForNetworkIdle(); } - async function setMcpEnabled(enabled) + async function isRawApiAccessScopeVisible() + { + return page.evaluate((selector) => { + const element = document.querySelector(selector); + + if (!element) { + return false; + } + + return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); + }, rawApiAccessScopeSelector); + } + + async function configureMcp( + enabled, + maximumMcpAccessLevel = 'string:unlimited', + rawApiAccessScope = 'string:partial', + rawApiAccessLevels = [] + ) { 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(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(); + } + + 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); @@ -92,14 +154,25 @@ 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 isRawApiAccessScopeVisible()).to.equal(false); + }); + + it('should display the plugin settings when MCP is enabled with partial API access', async function () { + 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'); }); it('should show connect guidance for superusers when MCP is disabled', async function () { - await setMcpEnabled(false); + await configureMcp(false); await page.goto(connectUrl); await page.waitForNetworkIdle(); @@ -115,7 +188,7 @@ describe('McpServer', function () { }); it('should show contact-admin guidance for view users when MCP is disabled', async function () { - await setMcpEnabled(false); + await configureMcp(false); setViewUser(); await page.goto(connectUrl); @@ -134,10 +207,10 @@ describe('McpServer', function () { }); it('should display the connect page when MCP is enabled', async function () { - await setMcpEnabled(true); + await configureMcp(true, 'string:unlimited', 'string:full'); await page.goto(connectUrl); await page.waitForNetworkIdle(); - await page.waitForSelector(`${connectSelector} .card-action`, { visible: true }); + await page.waitForSelector(connectSelector, { visible: true }); await page.mouse.move(-10, -10); expect(await page.screenshotSelector(connectSelector)).to.matchImage('connect_enabled'); diff --git a/tests/UI/expected-ui-screenshots/McpServer_settings.png b/tests/UI/expected-ui-screenshots/McpServer_settings.png index 954b2b3..c603ba5 100644 Binary files a/tests/UI/expected-ui-screenshots/McpServer_settings.png and b/tests/UI/expected-ui-screenshots/McpServer_settings.png differ diff --git a/tests/Unit/APITest.php b/tests/Unit/APITest.php index b737d19..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'; @@ -301,6 +366,7 @@ private function createFactory(): McpServerFactory new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createMock(SystemSettings::class), ); } @@ -309,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) @@ -319,13 +389,14 @@ private function createApiWithRequest( new McpEndpointGuard(), new JsonRpcErrorResponseFactory(), new JsonRpcRequestIdExtractor(), - $this->createMock(SystemSettings::class), + $systemSettings, ]) ->onlyMethods([ 'createRequestFromGlobals', 'isCurrentApiRequestRoot', 'getRootApiRequestMethod', 'isMcpEnabled', + 'isCurrentUserPrivilegeLevelAllowed', ]) ->getMock(); @@ -337,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/McpServerFactoryTest.php b/tests/Unit/McpServerFactoryTest.php index d40f9c6..3479b22 100644 --- a/tests/Unit/McpServerFactoryTest.php +++ b/tests/Unit/McpServerFactoryTest.php @@ -12,13 +12,20 @@ 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; 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; use Psr\Container\ContainerInterface; @@ -55,6 +62,7 @@ public function testInitializeResponseHasExpectedServerInfoAndCapabilities(): vo new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $payload = McpTestHelper::makeInitializeRequest('init-1'); @@ -93,6 +101,7 @@ public function testToolCallLoggingEnabledInjectsObservedHandler(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -129,6 +138,7 @@ public function testStringOneConfigEnablesFullParameterLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -153,6 +163,7 @@ public function testBooleanTrueConfigEnablesToolCallLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -177,6 +188,7 @@ public function testToolCallLoggingDisabledSkipsObservedHandlerInjection(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -201,6 +213,7 @@ public function testStringZeroConfigDisablesToolCallLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -225,6 +238,7 @@ public function testStringTrueConfigDoesNotEnableToolCallLogging(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -249,6 +263,7 @@ public function testToolCallLoggingMissingConfigSkipsObservedHandlerInjection(): new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -276,6 +291,7 @@ public function testConfiguredWarnLevelUsesWarning(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -303,6 +319,7 @@ public function testConfiguredErrorLevelUsesError(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -330,6 +347,7 @@ public function testConfiguredInfoLevelUsesInfo(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -356,6 +374,7 @@ public function testConfiguredVerboseLevelUsesDebug(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -385,6 +404,7 @@ public function testInvalidToolCallLogLevelFallsBackToDebug(): void new InMemorySessionStore(), $this->createMock(ContainerInterface::class), new ToolCallParameterFormatter(), + $this->createSystemSettingsStub(), ); $server = $factory->createServer(); $sessionId = McpTestHelper::initializeSession($server); @@ -395,4 +415,143 @@ public function testInvalidToolCallLogLevelFallsBackToDebug(): void self::assertSame(JsonRpcError::METHOD_NOT_FOUND, $message->code); } + + public function testRawApiListToolIsVisibleWhenRawAccessModeAllowsDirectApiAccess(): void + { + $toolsWhenRead = $this->listToolNamesForCurrentConfig('read'); + 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(ApiCallCreate::TOOL_NAME, $toolsWhenCreate); + self::assertContains('matomo_api_get', $toolsWhenCreate); + self::assertContains('matomo_api_list', $toolsWhenCreate); + + $toolsWhenUpdate = $this->listToolNamesForCurrentConfig('update'); + self::assertContains(ApiCallUpdate::TOOL_NAME, $toolsWhenUpdate); + self::assertContains('matomo_api_get', $toolsWhenUpdate); + self::assertContains('matomo_api_list', $toolsWhenUpdate); + + $toolsWhenDelete = $this->listToolNamesForCurrentConfig('delete'); + self::assertContains(ApiCallDelete::TOOL_NAME, $toolsWhenDelete); + self::assertContains('matomo_api_get', $toolsWhenDelete); + self::assertContains('matomo_api_list', $toolsWhenDelete); + + $toolsWhenFull = $this->listToolNamesForCurrentConfig('full'); + 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); + } + + public function testRawApiGetToolHasFullAnnotationsWhenVisible(): void + { + $toolsWhenRead = $this->listToolsByNameForCurrentConfig('read'); + + 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); + + $toolsWhenFull = $this->listToolsByNameForCurrentConfig('full'); + + 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); + } + + public function testRawApiCallToolsHaveExpectedAnnotationsWhenVisible(): void + { + $toolsWhenRead = $this->listToolsByNameForCurrentConfig('read'); + + self::assertArrayHasKey(ApiCallRead::TOOL_NAME, $toolsWhenRead); + $toolWhenRead = $toolsWhenRead[ApiCallRead::TOOL_NAME]; + self::assertNotNull($toolWhenRead->annotations); + self::assertTrue($toolWhenRead->annotations->readOnlyHint); + self::assertFalse($toolWhenRead->annotations->destructiveHint); + self::assertTrue($toolWhenRead->annotations->idempotentHint); + self::assertFalse($toolWhenRead->annotations->openWorldHint); + + $toolsWhenFull = $this->listToolsByNameForCurrentConfig('full'); + + 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); + self::assertFalse($toolWhenFull->annotations->idempotentHint); + self::assertFalse($toolWhenFull->annotations->openWorldHint); + } + + /** + * @return list + */ + private function listToolNamesForCurrentConfig(string $rawApiAccessMode = 'none'): array + { + return array_keys($this->listToolsByNameForCurrentConfig($rawApiAccessMode)); + } + + /** + * @return 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); + $payload = McpTestHelper::makeListToolsRequest('list-tools-1'); + + $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; + } + + 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 new file mode 100644 index 0000000..06aa59f --- /dev/null +++ b/tests/Unit/McpTools/ApiCallTest.php @@ -0,0 +1,273 @@ +values = []; + + $record = new ApiMethodSummaryRecord( + 'API', + 'getMatomoVersion', + 'API.getMatomoVersion', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ); + + $tool = new ApiCallRead( + new class ($captured) implements ApiCallQueryServiceInterface { + public function __construct(private stdClass $captured) + { + } + + public function callApi( + ApiMethodSummaryRecord $resolvedMethod, + ?array $parameters = null, + ): ApiCallRecord { + $this->captured->values = [ + 'resolvedMethod' => $resolvedMethod, + 'parameters' => $parameters, + ]; + + return new ApiCallRecord('6.0.0', $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'), + ); + + $actual = $tool->call(method: ' API.getMatomoVersion '); + + self::assertSame([ + 'result' => '6.0.0', + 'resolvedMethod' => [ + 'module' => 'API', + 'action' => 'getMatomoVersion', + 'method' => 'API.getMatomoVersion', + 'parameters' => [], + 'operationCategory' => 'read', + ], + ], $actual); + /** @var array $capturedValues */ + $capturedValues = $captured->values; + self::assertInstanceOf(ApiMethodSummaryRecord::class, $capturedValues['resolvedMethod']); + self::assertSame('API.getMatomoVersion', $capturedValues['resolvedMethod']->method); + self::assertNull($capturedValues['parameters']); + } + + public function testCallUsesSplitSelectorAndParameters(): void + { + $captured = new stdClass(); + $captured->values = []; + + $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( + ApiMethodSummaryRecord $resolvedMethod, + ?array $parameters = null, + ): ApiCallRecord { + $this->captured->values = [ + 'resolvedMethod' => $resolvedMethod, + 'parameters' => $parameters, + ]; + + return new ApiCallRecord( + ['success' => true], + new ApiMethodSummaryRecord( + 'UsersManager', + 'addUser', + 'UsersManager.addUser', + [], + ApiMethodOperationClassifier::CATEGORY_CREATE, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:add', + ), + ); + } + }, + $this->createMethodSummaryQueryServiceStub($record), + $this->createSystemSettingsStub('full'), + ); + + $actual = $tool->call( + module: ' UsersManager ', + action: ' addUser ', + parameters: ['userLogin' => 'alice'], + ); + + self::assertSame(['success' => true], $actual['result']); + /** @var array $capturedValues */ + $capturedValues = $captured->values; + self::assertInstanceOf(ApiMethodSummaryRecord::class, $capturedValues['resolvedMethod']); + self::assertSame('UsersManager.addUser', $capturedValues['resolvedMethod']->method); + self::assertSame(['userLogin' => 'alice'], $capturedValues['parameters']); + } + + public function testCallNormalizesEmptyParameterList(): void + { + $captured = new stdClass(); + $captured->values = []; + + $record = new ApiMethodSummaryRecord( + 'API', + 'getMatomoVersion', + 'API.getMatomoVersion', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ); + + $tool = new ApiCallRead( + new class ($captured) implements ApiCallQueryServiceInterface { + public function __construct(private stdClass $captured) + { + } + + public function callApi( + ApiMethodSummaryRecord $resolvedMethod, + ?array $parameters = null, + ): ApiCallRecord { + $this->captured->values = [ + 'resolvedMethod' => $resolvedMethod, + 'parameters' => $parameters, + ]; + + return new ApiCallRecord('6.0.0', $resolvedMethod); + } + }, + $this->createMethodSummaryQueryServiceStub($record), + $this->createSystemSettingsStub('read'), + ); + + $actual = $tool->call(method: 'API.getMatomoVersion', parameters: []); + + self::assertSame('6.0.0', $actual['result']); + /** @var array $capturedValues */ + $capturedValues = $captured->values; + self::assertSame([], $capturedValues['parameters']); + } + + public function testCallRejectsNonEmptyParameterList(): void + { + $record = new ApiMethodSummaryRecord( + 'API', + 'getMatomoVersion', + 'API.getMatomoVersion', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ); + + $tool = new ApiCallRead( + $this->createMock(ApiCallQueryServiceInterface::class), + $this->createMethodSummaryQueryServiceStub($record), + $this->createSystemSettingsStub('read'), + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('parameters is invalid.'); + + /** @phpstan-ignore-next-line intentional invalid direct invocation coverage */ + $tool->call(method: 'API.getMatomoVersion', parameters: ['flat']); + } + + public function testScopedToolRejectsMethodOutsideExpectedOperationCategory(): void + { + $tool = new ApiCallRead( + $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); + $settings->method('getRawApiAccessMode') + ->willReturn($rawApiAccessMode); + + return $settings; + } +} diff --git a/tests/Unit/McpTools/ApiGetTest.php b/tests/Unit/McpTools/ApiGetTest.php new file mode 100644 index 0000000..445a016 --- /dev/null +++ b/tests/Unit/McpTools/ApiGetTest.php @@ -0,0 +1,157 @@ +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: [], + operationCategory: ApiMethodOperationClassifier::CATEGORY_READ, + classificationConfidence: ApiMethodOperationClassifier::CONFIDENCE_HIGH, + classificationReason: 'action-prefix:get', + ); + } + }, + $this->createSystemSettingsStub('read'), + ); + + $actual = $tool->get(method: ' API.getMatomoVersion '); + + self::assertSame([ + 'module' => 'API', + 'action' => 'getMatomoVersion', + 'method' => 'API.getMatomoVersion', + 'parameters' => [], + 'operationCategory' => 'read', + ], $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 + { + $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: [], + operationCategory: ApiMethodOperationClassifier::CATEGORY_CREATE, + classificationConfidence: ApiMethodOperationClassifier::CONFIDENCE_HIGH, + classificationReason: 'action-prefix:add', + ); + } + }, + $this->createSystemSettingsStub('full'), + ); + + $actual = $tool->get(module: ' UsersManager ', action: ' addUser '); + + self::assertSame([ + 'module' => 'UsersManager', + 'action' => 'addUser', + 'method' => 'UsersManager.addUser', + 'parameters' => [], + 'operationCategory' => 'create', + ], $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']); + } + + 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 new file mode 100644 index 0000000..8762766 --- /dev/null +++ b/tests/Unit/McpTools/ApiListTest.php @@ -0,0 +1,503 @@ +createQueryServiceStub( + static fn(ApiMethodSummaryQueryRecord $query): array => [ + new ApiMethodSummaryRecord( + 'UsersManager', + 'getUsers', + 'UsersManager.getUsers', + [], + ApiMethodOperationClassifier::CATEGORY_READ, + ApiMethodOperationClassifier::CONFIDENCE_HIGH, + 'action-prefix:get', + ), + ] + ), + new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('read'), + ); + + $actual = $tool->list(limit: 10, sort: ApiMethodsPagination::SORT_METHOD_ASC); + + self::assertSame([ + 'methods' => [ + [ + 'module' => 'UsersManager', + 'action' => 'getUsers', + 'method' => 'UsersManager.getUsers', + 'parameters' => [], + 'operationCategory' => 'read', + ], + ], + 'next_cursor' => null, + 'has_more' => false, + 'total_rows' => 1, + ], $actual); + } + + public function testListReturnsAllMethodsInFullModeAndSupportsFilters(): void + { + $capturedQuery = null; + + $tool = new ApiList( + $this->createQueryServiceStub( + static function (ApiMethodSummaryQueryRecord $query) use (&$capturedQuery): array { + $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', category: 'create', limit: 10); + + self::assertSame([ + 'methods' => [ + [ + 'module' => 'UsersManager', + 'action' => 'addUser', + 'method' => 'UsersManager.addUser', + 'parameters' => [], + 'operationCategory' => 'create', + ], + ], + '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); + 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 + { + $tool = new ApiList( + $this->createQueryServiceStub(static fn(ApiMethodSummaryQueryRecord $query): array => []), + new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), + ); + + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Invalid cursor.'); + + $tool->list(cursor: 'invalid'); + } + + public function testListSupportsPaginationAndSortOrdering(): void + { + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), + ); + + $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(6, $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(6, $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 + { + $rawApiAccessMode = 'read'; + $settings = $this->createMutableSystemSettingsStub($rawApiAccessMode); + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + $settings, + ); + $firstPage = $tool->list(limit: 1, sort: ApiMethodsPagination::SORT_METHOD_ASC); + $cursor = $firstPage['next_cursor'] ?? null; + self::assertIsString($cursor); + + $rawApiAccessMode = 'full'; + $this->expectException(ToolCallException::class); + $this->expectExceptionMessage('Invalid cursor.'); + $tool->list(limit: 1, cursor: $cursor, sort: ApiMethodsPagination::SORT_METHOD_ASC); + } + + public function testListRejectsCursorWhenSearchChanges(): void + { + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), + ); + + $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 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( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), + ); + + $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 + { + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), + ); + + $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 + { + $tool = new ApiList( + $this->createExpandedQueryServiceStub(), + new PaginatedCollectionResponder(new CursorPaginator()), + $this->createSystemSettingsStub('full'), + ); + + $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); + } + + public function getApiMethodSummaryBySelector( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ApiMethodSummaryRecord { + throw new \BadMethodCallException('Not used in ApiList tests.'); + } + }; + } + + private function createExpandedQueryServiceStub(): ApiMethodSummaryQueryServiceInterface + { + return new class () implements ApiMethodSummaryQueryServiceInterface { + public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array + { + $records = [ + 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( + $records, + static function (ApiMethodSummaryRecord $record) use ($query): bool { + if (!RawApiAccessMode::allowsCategory($query->accessMode, $record->operationCategory)) { + return false; + } + + if ($query->module !== '' && strtolower($record->module) !== $query->module) { + return false; + } + + if ($query->search === '') { + if ($query->operationCategory === '') { + return true; + } + + return $record->operationCategory === $query->operationCategory; + } + + $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; + }, + )); + } + + public function getApiMethodSummaryBySelector( + string $accessMode, + ?string $method = null, + ?string $module = null, + ?string $action = null, + ): ApiMethodSummaryRecord { + throw new \BadMethodCallException('Not used in ApiList tests.'); + } + }; + } + + 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; + } +} diff --git a/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php new file mode 100644 index 0000000..52610d4 --- /dev/null +++ b/tests/Unit/Services/Api/ApiMethodSummaryQueryServiceTest.php @@ -0,0 +1,483 @@ +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 + { + $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 testFilterRecordsUsesExplicitCrudModesForClassifiedMethods(): void + { + $service = new ApiMethodSummaryQueryService(); + + $readRecords = $service->filterRecords( + [ + 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'), + ); + $readCreateRecords = $service->filterRecords( + $this->createMethodRecords(), + ApiMethodSummaryQueryRecord::fromInputs('read,create'), + ); + $fullRecords = $service->filterRecords( + [ + new ApiMethodSummaryRecord( + 'ScheduledReports', + 'sendReport', + 'ScheduledReports.sendReport', + [], + null, + ApiMethodOperationClassifier::CONFIDENCE_LOW, + 'unsupported-action-prefix:send', + ), + ], + ApiMethodSummaryQueryRecord::fromInputs('full'), + ); + + self::assertSame( + ['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', + 'SitesManager.isSiteNameUnique', + 'UsersManager.addUser', + 'UsersManager.getUsers', + ], + array_values(array_map( + static fn(ApiMethodSummaryRecord $record): string => $record->method, + $readCreateRecords, + )), + ); + self::assertSame( + ['ScheduledReports.sendReport'], + 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(); + + $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', '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(); + + $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 + */ + private function createMethodRecords(): array + { + return [ + 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', + ), + ]; + } +} + +class InternalMethodFixture +{ + /** + * @internal + */ + public function hiddenMethod(): void + { + } +} + +/** + * @internal + */ +class InternalClassFixture +{ + public function visibleMethod(): void + { + } +} diff --git a/tests/Unit/Services/ApiCallQueryServiceTest.php b/tests/Unit/Services/ApiCallQueryServiceTest.php new file mode 100644 index 0000000..f6ef6ff --- /dev/null +++ b/tests/Unit/Services/ApiCallQueryServiceTest.php @@ -0,0 +1,309 @@ +callApi($resolvedMethod); + + self::assertSame('6.0.0', $record->result); + self::assertSame('API.getMatomoVersion', $record->resolvedMethod->method); + } + + public function testCallApiPassesParametersAndNormalizesObjects(): void + { + $resolvedMethod = new ApiMethodSummaryRecord('API', 'getSettings', 'API.getSettings', []); + $service = new ApiCallQueryService( + 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($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 => [ + 'label' => '/pricing', + 'nb_visits' => 4, + ], + ])); + + $service = new ApiCallQueryService( + 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($resolvedMethod); + + self::assertSame([ + [ + 'label' => '/pricing', + 'nb_visits' => 4, + ], + ], $record->result); + } + + public function testCallApiNormalizesNestedDataTableMapResultsViaJsonRenderer(): void + { + $resolvedMethod = new ApiMethodSummaryRecord('Live', 'getLastVisitDetails', 'Live.getLastVisitDetails', []); + $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( + 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($resolvedMethod); + + 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 + { + $resolvedMethod = new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []); + $service = new ApiCallQueryService( + 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($resolvedMethod, parameters: ['format' => 'json']); + } + + public function testCallApiMapsAccessDeniedFailures(): void + { + $resolvedMethod = new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []); + $service = new ApiCallQueryService( + 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($resolvedMethod); + } + + public function testCallApiMapsUpstreamFailures(): void + { + $resolvedMethod = new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []); + $service = new ApiCallQueryService( + 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($resolvedMethod); + } + + public function testCallApiSurfacesSanitizedValidationFailureDetail(): void + { + $resolvedMethod = new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []); + $service = new ApiCallQueryService( + 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($resolvedMethod); + } + + public function testCallApiKeepsGenericFailureForUnsafeUpstreamDetail(): void + { + $resolvedMethod = new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []); + $service = new ApiCallQueryService( + 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($resolvedMethod); + } + + public function testCallApiKeepsGenericFailureWhenNoSafeDetailExists(): void + { + $resolvedMethod = new ApiMethodSummaryRecord('UsersManager', 'addUser', 'UsersManager.addUser', []); + $service = new ApiCallQueryService( + 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($resolvedMethod); + } + + public function testCallApiRejectsInvalidResponse(): void + { + $resource = fopen('php://memory', 'rb'); + self::assertIsResource($resource); + $resolvedMethod = new ApiMethodSummaryRecord('API', 'getMatomoVersion', 'API.getMatomoVersion', []); + + try { + $service = new ApiCallQueryService( + 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($resolvedMethod); + } finally { + fclose($resource); + } + } +} diff --git a/tests/Unit/Services/CoreApiCallGatewayTest.php b/tests/Unit/Services/CoreApiCallGatewayTest.php new file mode 100644 index 0000000..fd6d469 --- /dev/null +++ b/tests/Unit/Services/CoreApiCallGatewayTest.php @@ -0,0 +1,82 @@ + 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 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( + 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', []); + } +} 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 2aff614..9e3f443 100644 --- a/tests/Unit/Services/Reports/ReportProcessedQueryServiceTest.php +++ b/tests/Unit/Services/Reports/ReportProcessedQueryServiceTest.php @@ -1465,6 +1465,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..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,62 @@ 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); + $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/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 @@ +