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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ This document is repo-specific. Prefer it over generic Matomo assumptions when w
- `API.php`: main MCP API endpoint, request validation flow, auth/error handling, and hand-off into the server.
- `McpServer.php`: plugin lifecycle hooks, stylesheet registration, install/uninstall table setup.
- `McpServerFactory.php`: server/container wiring and MCP server construction.
- `McpToolsProvider.php`: resolves the built-in tool set and fires the `McpServer.addTools` / `McpServer.filterTools` events so other plugins can register or restrict MCP tools.
- `SystemSettings.php`: plugin settings, including MCP enablement.
- `plugin.json`, `composer.json`: plugin metadata and PHP dependency constraints.

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Changelog

### Unreleased

- Added `McpServer.addTools` and `McpServer.filterTools` events so other plugins can contribute or restrict MCP tool registrations.

### 5.0.4

- Updated `mcp/sdk` to 0.5.
Expand Down
92 changes: 4 additions & 88 deletions McpServerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,6 @@
use Piwik\Plugins\McpServer\Contracts\McpTool;
use Piwik\Plugins\McpServer\Contracts\McpToolAnnotations;
use Piwik\Plugins\McpServer\Contracts\McpToolIcon;
use Piwik\Plugins\McpServer\McpTools\ApiCallCreate;
use Piwik\Plugins\McpServer\McpTools\ApiCallDelete;
use Piwik\Plugins\McpServer\McpTools\ApiCallFull;
use Piwik\Plugins\McpServer\McpTools\ApiCallRead;
use Piwik\Plugins\McpServer\McpTools\ApiCallUpdate;
use Piwik\Plugins\McpServer\McpTools\ApiGet;
use Piwik\Plugins\McpServer\McpTools\ApiList;
use Piwik\Plugins\McpServer\McpTools\DimensionGet;
use Piwik\Plugins\McpServer\McpTools\DimensionList;
use Piwik\Plugins\McpServer\McpTools\GoalGet;
use Piwik\Plugins\McpServer\McpTools\GoalList;
use Piwik\Plugins\McpServer\McpTools\ReportList;
use Piwik\Plugins\McpServer\McpTools\ReportMetadata;
use Piwik\Plugins\McpServer\McpTools\ReportProcessed;
use Piwik\Plugins\McpServer\McpTools\SegmentGet;
use Piwik\Plugins\McpServer\McpTools\SegmentList;
use Piwik\Plugins\McpServer\McpTools\SiteGet;
use Piwik\Plugins\McpServer\McpTools\SiteList;
use Piwik\Plugins\McpServer\McpTools\SiteSearch;
use Piwik\Plugins\McpServer\Server\Handler\Request\CompatibleCallToolHandler;
use Piwik\Plugins\McpServer\Server\Handler\Request\ObservedCallToolHandler;
use Piwik\Plugins\McpServer\Server\InternalAccess;
Expand All @@ -58,34 +39,6 @@ final class McpServerFactory
/** @var array<int, string> */
private const VALID_TOOL_CALL_LOG_LEVELS = ['ERROR', 'WARN', 'WARNING', 'INFO', 'DEBUG', 'VERBOSE'];

/**
* Tools shipped by this plugin. Held as class-strings so DI/container
* construction stays in one place — see buildBuiltinTools().
*
* @var list<class-string<McpTool>>
*/
private const BUILTIN_TOOL_CLASSES = [
ApiCallCreate::class,
ApiCallDelete::class,
ApiCallFull::class,
ApiCallRead::class,
ApiCallUpdate::class,
ApiGet::class,
ApiList::class,
DimensionGet::class,
DimensionList::class,
GoalGet::class,
GoalList::class,
ReportList::class,
ReportMetadata::class,
ReportProcessed::class,
SegmentGet::class,
SegmentList::class,
SiteGet::class,
SiteList::class,
SiteSearch::class,
];

/** @var array{server: Server, registry: Registry, callToolHandler: RequestHandlerInterface<mixed>}|null */
private ?array $runtimeCache = null;

Expand All @@ -94,26 +47,12 @@ public function __construct(
private SessionStoreInterface $sessionStore,
private ContainerInterface $container,
private ToolCallParameterFormatter $toolCallParameterFormatter,
private McpToolsProviderInterface $toolsProvider,
) {
}

/**
* @param list<McpTool>|null $tools Built-in tools to register. When null,
* the factory resolves the shipped tool
* set via its container. Tests inject an
* explicit list to bypass DI.
* An explicit list bypasses the runtime
* cache and always builds a fresh server;
* it is never written back to the cache,
* so production calls (which pass null)
* keep seeing the shipped tool set.
*/
public function createServer(?array $tools = null): Server
public function createServer(): Server
{
if ($tools !== null) {
return $this->buildRuntime($tools)['server'];
}

return $this->resolveRuntime()['server'];
}

Expand Down Expand Up @@ -165,12 +104,11 @@ private function resolveRuntime(): array
}

/**
* @param list<McpTool>|null $tools
* @return array{server: Server, registry: Registry, callToolHandler: RequestHandlerInterface<mixed>}
*/
private function buildRuntime(?array $tools = null): array
private function buildRuntime(): array
{
$tools ??= $this->buildBuiltinTools();
$tools = $this->toolsProvider->getAllTools();

$version = (string) Manager::getInstance()->getVersion('McpServer');
$loggingConfig = $this->resolveLoggingConfig();
Expand Down Expand Up @@ -283,28 +221,6 @@ private function adaptIcons(?array $icons): ?array
);
}

/**
* Resolve the built-in tool class-strings into instances.
*
* @return list<McpTool>
*/
private function buildBuiltinTools(): array
{
$tools = [];
foreach (self::BUILTIN_TOOL_CLASSES as $toolClass) {
$tool = $this->container->get($toolClass);
if (!$tool instanceof McpTool) {
throw new \LogicException(sprintf(
'%s did not resolve to an McpTool instance.',
$toolClass,
));
}
$tools[] = $tool;
}

return $tools;
}

/**
* @return array{logToolCalls: bool, logFullParameters: bool, logLevel: string}
*/
Expand Down
193 changes: 193 additions & 0 deletions McpToolsProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
<?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;

use Piwik\EventDispatcher;
use Piwik\Plugins\McpServer\Contracts\McpTool;
use Piwik\Plugins\McpServer\McpTools\ApiCallCreate;
use Piwik\Plugins\McpServer\McpTools\ApiCallDelete;
use Piwik\Plugins\McpServer\McpTools\ApiCallFull;
use Piwik\Plugins\McpServer\McpTools\ApiCallRead;
use Piwik\Plugins\McpServer\McpTools\ApiCallUpdate;
use Piwik\Plugins\McpServer\McpTools\ApiGet;
use Piwik\Plugins\McpServer\McpTools\ApiList;
use Piwik\Plugins\McpServer\McpTools\DimensionGet;
use Piwik\Plugins\McpServer\McpTools\DimensionList;
use Piwik\Plugins\McpServer\McpTools\GoalGet;
use Piwik\Plugins\McpServer\McpTools\GoalList;
use Piwik\Plugins\McpServer\McpTools\ReportList;
use Piwik\Plugins\McpServer\McpTools\ReportMetadata;
use Piwik\Plugins\McpServer\McpTools\ReportProcessed;
use Piwik\Plugins\McpServer\McpTools\SegmentGet;
use Piwik\Plugins\McpServer\McpTools\SegmentList;
use Piwik\Plugins\McpServer\McpTools\SiteGet;
use Piwik\Plugins\McpServer\McpTools\SiteList;
use Piwik\Plugins\McpServer\McpTools\SiteSearch;
use Psr\Container\ContainerInterface;

/**
* Resolves the set of MCP tools exposed by the server.
*
* Built-in tools shipped by the McpServer plugin are listed in
* {@see McpToolsProvider::BUILTIN_TOOL_CLASSES}. Other plugins extend or
* restrict the set by listening to the {@hook McpServer.addTools} and
* {@hook McpServer.filterTools} events.
*/
class McpToolsProvider implements McpToolsProviderInterface
{
/**
* Tools shipped by the McpServer plugin. Held as class-strings so the
* container resolves them at runtime — see resolveBuiltinTools().
*
* @var list<class-string<McpTool>>
*/
private const BUILTIN_TOOL_CLASSES = [
ApiCallCreate::class,
ApiCallDelete::class,
ApiCallFull::class,
ApiCallRead::class,
ApiCallUpdate::class,
ApiGet::class,
ApiList::class,
DimensionGet::class,
DimensionList::class,
GoalGet::class,
GoalList::class,
ReportList::class,
ReportMetadata::class,
ReportProcessed::class,
SegmentGet::class,
SegmentList::class,
SiteGet::class,
SiteList::class,
SiteSearch::class,
];

public function __construct(
private ContainerInterface $container,
private EventDispatcher $eventDispatcher,
) {
}

/**
* Return all MCP tools that should be considered for registration.
*
* The list begins with the McpServer built-in tools, lets other plugins
* append their own via {@hook McpServer.addTools}, then lets plugins
* restrict the result via {@hook McpServer.filterTools}.
*
* Each entry MUST be an instance of {@see McpTool}; entries that fail
* this check trigger a LogicException so misconfigurations surface at
* boot rather than at tool-call time.
*
* @return list<McpTool>
*/
public function getAllTools(): array
{
// Listeners receive the list by reference and can replace it with any
// value, so validate the result at boot — both that it is still an
// array and that every entry is an McpTool — rather than failing later
// at tool-call time.
$contributed = $this->dispatchToolEvents($this->resolveBuiltinTools());
if (!is_array($contributed)) {
throw new \LogicException(sprintf(
'McpServer tool list must be an array, got %s.',
get_debug_type($contributed),
));
}

$result = [];
foreach ($contributed as $tool) {
if (!$tool instanceof McpTool) {
throw new \LogicException(sprintf(
'McpServer tool list contained a non-McpTool entry: %s.',
get_debug_type($tool),
));
}
$result[] = $tool;
}

return $result;
}

/**
* Pass the built-in tool list through the add/filter events and return
* whatever the listeners left behind.
*
* Listeners mutate the list by reference and are not constrained by the
* static type system, so the return type is intentionally `mixed`; the
* caller validates it before use.
*
* @param list<McpTool> $tools
* @return mixed
*/
private function dispatchToolEvents(array $tools)
{
/**
* Triggered to add custom MCP tools that ship outside the McpServer
* plugin. Tools added here are subsequently passed through the
* {@hook McpServer.filterTools} event before being registered.
*
* **Example**
*
* public function addMcpTools(&$tools)
* {
* $tools[] = StaticContainer::get(MyCustomMcpTool::class);
* }
*
* @param McpTool[] &$tools An array of MCP tool instances.
*/
$this->eventDispatcher->postEvent('McpServer.addTools', [&$tools]);

/**
* Triggered to filter or restrict the set of MCP tools that will be
* registered with the server. Use this to hide tools conditionally
* (for example based on plugin state or system settings).
*
* **Example**
*
* public function filterMcpTools(&$tools)
* {
* foreach ($tools as $index => $tool) {
* if ($tool->getName() === 'matomo_site_list') {
* unset($tools[$index]);
* }
* }
* }
*
* @param McpTool[] &$tools An array of MCP tool instances.
*/
$this->eventDispatcher->postEvent('McpServer.filterTools', [&$tools]);

return $tools;
}

/**
* @return list<McpTool>
*/
private function resolveBuiltinTools(): array
{
$tools = [];
foreach (self::BUILTIN_TOOL_CLASSES as $toolClass) {
$tool = $this->container->get($toolClass);
if (!$tool instanceof McpTool) {
throw new \LogicException(sprintf(
'%s did not resolve to an McpTool instance.',
$toolClass,
));
}
$tools[] = $tool;
}

return $tools;
}
}
31 changes: 31 additions & 0 deletions McpToolsProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?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;

use Piwik\Plugins\McpServer\Contracts\McpTool;

/**
* Resolves the set of MCP tools exposed by the server.
*
* The default implementation {@see McpToolsProvider} lists the McpServer
* built-in tools and lets other plugins extend or restrict the set via the
* {@hook McpServer.addTools} and {@hook McpServer.filterTools} events.
*/
interface McpToolsProviderInterface
{
/**
* Return all MCP tools that should be considered for registration.
*
* @return list<McpTool>
*/
public function getAllTools(): array;
}
3 changes: 3 additions & 0 deletions config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
use Piwik\Plugins\McpServer\Contracts\Ports\Sites\SiteDetailQueryServiceInterface;
use Piwik\Plugins\McpServer\Contracts\Ports\Sites\SiteSummaryQueryServiceInterface;
use Piwik\Plugins\McpServer\Contracts\Ports\System\PluginCapabilityGatewayInterface;
use Piwik\Plugins\McpServer\McpToolsProvider;
use Piwik\Plugins\McpServer\McpToolsProviderInterface;
use Piwik\Plugins\McpServer\Services\Api\ApiCallQueryService;
use Piwik\Plugins\McpServer\Services\Api\ApiMethodSummaryQueryService;
use Piwik\Plugins\McpServer\Services\Api\CoreApiCallGateway;
Expand Down Expand Up @@ -68,6 +70,7 @@
DimensionSummaryQueryServiceInterface::class => DI::autowire(DimensionSummaryQueryService::class),
GoalDetailQueryServiceInterface::class => DI::autowire(GoalDetailQueryService::class),
GoalSummaryQueryServiceInterface::class => DI::autowire(GoalSummaryQueryService::class),
McpToolsProviderInterface::class => DI::autowire(McpToolsProvider::class),
PluginCapabilityGatewayInterface::class => DI::autowire(PluginCapabilityGateway::class),
ReportMetadataQueryServiceInterface::class => DI::autowire(ReportMetadataQueryService::class),
ReportProcessedQueryServiceInterface::class => DI::autowire(ReportProcessedQueryService::class),
Expand Down
Loading
Loading