Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions API.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
);
}
}
26 changes: 26 additions & 0 deletions Contracts/Ports/Api/ApiCallQueryServiceInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/

declare(strict_types=1);

namespace Piwik\Plugins\McpServer\Contracts\Ports\Api;

use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiCallRecord;
use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord;

interface ApiCallQueryServiceInterface
{
/**
* @param array<string, mixed>|null $parameters
*/
public function callApi(
ApiMethodSummaryRecord $resolvedMethod,
?array $parameters = null,
): ApiCallRecord;
}
30 changes: 30 additions & 0 deletions Contracts/Ports/Api/ApiMethodSummaryQueryServiceInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/

declare(strict_types=1);

namespace Piwik\Plugins\McpServer\Contracts\Ports\Api;

use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryQueryRecord;
use Piwik\Plugins\McpServer\Contracts\Records\Api\ApiMethodSummaryRecord;

interface ApiMethodSummaryQueryServiceInterface
{
/**
* @return array<int, ApiMethodSummaryRecord>
*/
public function getApiMethodSummaries(ApiMethodSummaryQueryRecord $query): array;

public function getApiMethodSummaryBySelector(
string $accessMode,
?string $method = null,
?string $module = null,
?string $action = null,
): ApiMethodSummaryRecord;
}
20 changes: 20 additions & 0 deletions Contracts/Ports/Api/CoreApiCallGatewayInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/

declare(strict_types=1);

namespace Piwik\Plugins\McpServer\Contracts\Ports\Api;

interface CoreApiCallGatewayInterface
{
/**
* @param array<string, mixed> $parameters
*/
public function call(string $method, array $parameters): mixed;
}
39 changes: 39 additions & 0 deletions Contracts/Records/Api/ApiCallRecord.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/

declare(strict_types=1);

namespace Piwik\Plugins\McpServer\Contracts\Records\Api;

/**
* @phpstan-import-type ApiMethodSummaryArray from ApiMethodSummaryRecord
* @phpstan-type ApiCallArray array{
* result: mixed,
* resolvedMethod: ApiMethodSummaryArray,
* }
*/
final class ApiCallRecord
{
public function __construct(
public readonly mixed $result,
public readonly ApiMethodSummaryRecord $resolvedMethod,
) {
}

/**
* @return ApiCallArray
*/
public function toArray(): array
{
return [
'result' => $this->result,
'resolvedMethod' => $this->resolvedMethod->toArray(),
];
}
}
40 changes: 40 additions & 0 deletions Contracts/Records/Api/ApiMethodSummaryQueryRecord.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/

declare(strict_types=1);

namespace Piwik\Plugins\McpServer\Contracts\Records\Api;

use Piwik\Plugins\McpServer\Support\Access\RawApiAccessMode;
use Piwik\Plugins\McpServer\Support\Api\ApiMethodOperationClassifier;

final class ApiMethodSummaryQueryRecord
{
public function __construct(
public readonly string $accessMode,
public readonly string $module,
public readonly string $search,
public readonly string $operationCategory,
) {
}

public static function fromInputs(
string $accessMode,
?string $module = null,
?string $search = null,
?string $operationCategory = null,
): self {
return new self(
accessMode: RawApiAccessMode::normalize($accessMode),
module: strtolower(trim((string) $module)),
search: strtolower(trim((string) $search)),
operationCategory: ApiMethodOperationClassifier::normalizeCategory($operationCategory),
);
}
}
60 changes: 60 additions & 0 deletions Contracts/Records/Api/ApiMethodSummaryRecord.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/

declare(strict_types=1);

namespace Piwik\Plugins\McpServer\Contracts\Records\Api;

/**
* @phpstan-type ApiMethodParameterArray array{
* name: string,
* type: string|null,
* required: bool,
* allowsNull: bool,
* hasDefault: bool,
* defaultValue: mixed,
* }
* @phpstan-type ApiMethodSummaryArray array{
* module: string,
* action: string,
* method: string,
* parameters: list<ApiMethodParameterArray>,
* operationCategory: string|null,
* }
*/
final class ApiMethodSummaryRecord
{
/** @param list<ApiMethodParameterArray> $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,
];
}
}
Loading
Loading