Skip to content

Commit 6cbb8ae

Browse files
committed
Add McpToolsProvider with addTools/filterTools events
1 parent a293b6b commit 6cbb8ae

8 files changed

Lines changed: 444 additions & 75 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ This document is repo-specific. Prefer it over generic Matomo assumptions when w
2323
- `API.php`: main MCP API endpoint, request validation flow, auth/error handling, and hand-off into the server.
2424
- `McpServer.php`: plugin lifecycle hooks, stylesheet registration, install/uninstall table setup.
2525
- `McpServerFactory.php`: server/container wiring and MCP server construction.
26+
- `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.
2627
- `SystemSettings.php`: plugin settings, including MCP enablement.
2728
- `plugin.json`, `composer.json`: plugin metadata and PHP dependency constraints.
2829

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
## Changelog
22

3+
### Unreleased
4+
5+
- Added `McpServer.addTools` and `McpServer.filterTools` events so other plugins can contribute or restrict MCP tool registrations.
6+
37
### 5.0.4
48

59
- Updated `mcp/sdk` to 0.5.

McpServerFactory.php

Lines changed: 8 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,6 @@
2525
use Piwik\Plugins\McpServer\Contracts\McpTool;
2626
use Piwik\Plugins\McpServer\Contracts\McpToolAnnotations;
2727
use Piwik\Plugins\McpServer\Contracts\McpToolIcon;
28-
use Piwik\Plugins\McpServer\McpTools\ApiCallCreate;
29-
use Piwik\Plugins\McpServer\McpTools\ApiCallDelete;
30-
use Piwik\Plugins\McpServer\McpTools\ApiCallFull;
31-
use Piwik\Plugins\McpServer\McpTools\ApiCallRead;
32-
use Piwik\Plugins\McpServer\McpTools\ApiCallUpdate;
33-
use Piwik\Plugins\McpServer\McpTools\ApiGet;
34-
use Piwik\Plugins\McpServer\McpTools\ApiList;
35-
use Piwik\Plugins\McpServer\McpTools\DimensionGet;
36-
use Piwik\Plugins\McpServer\McpTools\DimensionList;
37-
use Piwik\Plugins\McpServer\McpTools\GoalGet;
38-
use Piwik\Plugins\McpServer\McpTools\GoalList;
39-
use Piwik\Plugins\McpServer\McpTools\ReportList;
40-
use Piwik\Plugins\McpServer\McpTools\ReportMetadata;
41-
use Piwik\Plugins\McpServer\McpTools\ReportProcessed;
42-
use Piwik\Plugins\McpServer\McpTools\SegmentGet;
43-
use Piwik\Plugins\McpServer\McpTools\SegmentList;
44-
use Piwik\Plugins\McpServer\McpTools\SiteGet;
45-
use Piwik\Plugins\McpServer\McpTools\SiteList;
46-
use Piwik\Plugins\McpServer\McpTools\SiteSearch;
4728
use Piwik\Plugins\McpServer\Server\Handler\Request\CompatibleCallToolHandler;
4829
use Piwik\Plugins\McpServer\Server\Handler\Request\ObservedCallToolHandler;
4930
use Piwik\Plugins\McpServer\Support\Logging\ToolCallParameterFormatter;
@@ -56,51 +37,26 @@ final class McpServerFactory
5637
/** @var array<int, string> */
5738
private const VALID_TOOL_CALL_LOG_LEVELS = ['ERROR', 'WARN', 'WARNING', 'INFO', 'DEBUG', 'VERBOSE'];
5839

59-
/**
60-
* Tools shipped by this plugin. Held as class-strings so DI/container
61-
* construction stays in one place — see buildBuiltinTools().
62-
*
63-
* @var list<class-string<McpTool>>
64-
*/
65-
private const BUILTIN_TOOL_CLASSES = [
66-
ApiCallCreate::class,
67-
ApiCallDelete::class,
68-
ApiCallFull::class,
69-
ApiCallRead::class,
70-
ApiCallUpdate::class,
71-
ApiGet::class,
72-
ApiList::class,
73-
DimensionGet::class,
74-
DimensionList::class,
75-
GoalGet::class,
76-
GoalList::class,
77-
ReportList::class,
78-
ReportMetadata::class,
79-
ReportProcessed::class,
80-
SegmentGet::class,
81-
SegmentList::class,
82-
SiteGet::class,
83-
SiteList::class,
84-
SiteSearch::class,
85-
];
86-
8740
public function __construct(
8841
private LoggerInterface $logger,
8942
private SessionStoreInterface $sessionStore,
9043
private ContainerInterface $container,
9144
private ToolCallParameterFormatter $toolCallParameterFormatter,
45+
private McpToolsProvider $toolsProvider,
9246
) {
9347
}
9448

9549
/**
96-
* @param list<McpTool>|null $tools Built-in tools to register. When null,
97-
* the factory resolves the shipped tool
98-
* set via its container. Tests inject an
99-
* explicit list to bypass DI.
50+
* @param list<McpTool>|null $tools Tools to register. When null, the
51+
* factory resolves the full tool set
52+
* (built-ins plus plugin-contributed
53+
* tools) via {@see McpToolsProvider}.
54+
* Tests inject an explicit list to
55+
* bypass that path.
10056
*/
10157
public function createServer(?array $tools = null): Server
10258
{
103-
$tools ??= $this->buildBuiltinTools();
59+
$tools ??= $this->toolsProvider->getAllTools();
10460

10561
$version = (string) Manager::getInstance()->getVersion('McpServer');
10662
$loggingConfig = $this->resolveLoggingConfig();
@@ -207,28 +163,6 @@ private function adaptIcons(?array $icons): ?array
207163
);
208164
}
209165

210-
/**
211-
* Resolve the built-in tool class-strings into instances.
212-
*
213-
* @return list<McpTool>
214-
*/
215-
private function buildBuiltinTools(): array
216-
{
217-
$tools = [];
218-
foreach (self::BUILTIN_TOOL_CLASSES as $toolClass) {
219-
$tool = $this->container->get($toolClass);
220-
if (!$tool instanceof McpTool) {
221-
throw new \LogicException(sprintf(
222-
'%s did not resolve to an McpTool instance.',
223-
$toolClass,
224-
));
225-
}
226-
$tools[] = $tool;
227-
}
228-
229-
return $tools;
230-
}
231-
232166
/**
233167
* @return array{logToolCalls: bool, logFullParameters: bool, logLevel: string}
234168
*/

McpToolsProvider.php

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
<?php
2+
3+
/**
4+
* Matomo - free/libre analytics platform
5+
*
6+
* @link https://matomo.org
7+
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
8+
*/
9+
10+
declare(strict_types=1);
11+
12+
namespace Piwik\Plugins\McpServer;
13+
14+
use Piwik\Piwik;
15+
use Piwik\Plugins\McpServer\Contracts\McpTool;
16+
use Piwik\Plugins\McpServer\McpTools\ApiCallCreate;
17+
use Piwik\Plugins\McpServer\McpTools\ApiCallDelete;
18+
use Piwik\Plugins\McpServer\McpTools\ApiCallFull;
19+
use Piwik\Plugins\McpServer\McpTools\ApiCallRead;
20+
use Piwik\Plugins\McpServer\McpTools\ApiCallUpdate;
21+
use Piwik\Plugins\McpServer\McpTools\ApiGet;
22+
use Piwik\Plugins\McpServer\McpTools\ApiList;
23+
use Piwik\Plugins\McpServer\McpTools\DimensionGet;
24+
use Piwik\Plugins\McpServer\McpTools\DimensionList;
25+
use Piwik\Plugins\McpServer\McpTools\GoalGet;
26+
use Piwik\Plugins\McpServer\McpTools\GoalList;
27+
use Piwik\Plugins\McpServer\McpTools\ReportList;
28+
use Piwik\Plugins\McpServer\McpTools\ReportMetadata;
29+
use Piwik\Plugins\McpServer\McpTools\ReportProcessed;
30+
use Piwik\Plugins\McpServer\McpTools\SegmentGet;
31+
use Piwik\Plugins\McpServer\McpTools\SegmentList;
32+
use Piwik\Plugins\McpServer\McpTools\SiteGet;
33+
use Piwik\Plugins\McpServer\McpTools\SiteList;
34+
use Piwik\Plugins\McpServer\McpTools\SiteSearch;
35+
use Psr\Container\ContainerInterface;
36+
37+
/**
38+
* Resolves the set of MCP tools exposed by the server.
39+
*
40+
* Built-in tools shipped by the McpServer plugin are listed in
41+
* {@see McpToolsProvider::BUILTIN_TOOL_CLASSES}. Other plugins extend or
42+
* restrict the set by listening to the {@hook McpServer.addTools} and
43+
* {@hook McpServer.filterTools} events.
44+
*/
45+
class McpToolsProvider
46+
{
47+
/**
48+
* Tools shipped by the McpServer plugin. Held as class-strings so the
49+
* container resolves them at runtime — see resolveBuiltinTools().
50+
*
51+
* @var list<class-string<McpTool>>
52+
*/
53+
private const BUILTIN_TOOL_CLASSES = [
54+
ApiCallCreate::class,
55+
ApiCallDelete::class,
56+
ApiCallFull::class,
57+
ApiCallRead::class,
58+
ApiCallUpdate::class,
59+
ApiGet::class,
60+
ApiList::class,
61+
DimensionGet::class,
62+
DimensionList::class,
63+
GoalGet::class,
64+
GoalList::class,
65+
ReportList::class,
66+
ReportMetadata::class,
67+
ReportProcessed::class,
68+
SegmentGet::class,
69+
SegmentList::class,
70+
SiteGet::class,
71+
SiteList::class,
72+
SiteSearch::class,
73+
];
74+
75+
public function __construct(private ContainerInterface $container)
76+
{
77+
}
78+
79+
/**
80+
* Return all MCP tools that should be considered for registration.
81+
*
82+
* The list begins with the McpServer built-in tools, lets other plugins
83+
* append their own via {@hook McpServer.addTools}, then lets plugins
84+
* restrict the result via {@hook McpServer.filterTools}.
85+
*
86+
* Each entry MUST be an instance of {@see McpTool}; entries that fail
87+
* this check trigger a LogicException so misconfigurations surface at
88+
* boot rather than at tool-call time.
89+
*
90+
* @return list<McpTool>
91+
*/
92+
public function getAllTools(): array
93+
{
94+
$tools = $this->resolveBuiltinTools();
95+
96+
/**
97+
* Triggered to add custom MCP tools that ship outside the McpServer
98+
* plugin. Tools added here are subsequently passed through the
99+
* {@hook McpServer.filterTools} event before being registered.
100+
*
101+
* **Example**
102+
*
103+
* public function addMcpTools(&$tools)
104+
* {
105+
* $tools[] = StaticContainer::get(MyCustomMcpTool::class);
106+
* }
107+
*
108+
* @param McpTool[] &$tools An array of MCP tool instances.
109+
*/
110+
Piwik::postEvent('McpServer.addTools', [&$tools]);
111+
112+
/**
113+
* Triggered to filter or restrict the set of MCP tools that will be
114+
* registered with the server. Use this to hide tools conditionally
115+
* (for example based on plugin state or system settings).
116+
*
117+
* **Example**
118+
*
119+
* public function filterMcpTools(&$tools)
120+
* {
121+
* foreach ($tools as $index => $tool) {
122+
* if ($tool->getName() === 'matomo_site_list') {
123+
* unset($tools[$index]);
124+
* }
125+
* }
126+
* }
127+
*
128+
* @param McpTool[] &$tools An array of MCP tool instances.
129+
*/
130+
Piwik::postEvent('McpServer.filterTools', [&$tools]);
131+
132+
// Listeners can mutate $tools to anything; check each entry at runtime
133+
// so a misconfigured plugin fails at boot rather than at tool-call time.
134+
/** @var array<int|string, mixed> $contributed */
135+
$contributed = $tools;
136+
$result = [];
137+
foreach ($contributed as $tool) {
138+
if (!$tool instanceof McpTool) {
139+
throw new \LogicException(sprintf(
140+
'McpServer tool list contained a non-McpTool entry: %s.',
141+
get_debug_type($tool),
142+
));
143+
}
144+
$result[] = $tool;
145+
}
146+
147+
return $result;
148+
}
149+
150+
/**
151+
* @return list<McpTool>
152+
*/
153+
private function resolveBuiltinTools(): array
154+
{
155+
$tools = [];
156+
foreach (self::BUILTIN_TOOL_CLASSES as $toolClass) {
157+
$tool = $this->container->get($toolClass);
158+
if (!$tool instanceof McpTool) {
159+
throw new \LogicException(sprintf(
160+
'%s did not resolve to an McpTool instance.',
161+
$toolClass,
162+
));
163+
}
164+
$tools[] = $tool;
165+
}
166+
167+
return $tools;
168+
}
169+
}

tests/Unit/APITest.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Piwik\Log\LoggerInterface;
2424
use Piwik\Plugins\McpServer\API;
2525
use Piwik\Plugins\McpServer\McpServerFactory;
26+
use Piwik\Plugins\McpServer\McpToolsProvider;
2627
use Piwik\Plugins\McpServer\Support\Api\JsonRpcErrorResponseFactory;
2728
use Piwik\Plugins\McpServer\Support\Api\JsonRpcRequestIdExtractor;
2829
use Piwik\Plugins\McpServer\Support\Api\McpEndpointGuard;
@@ -415,15 +416,18 @@ private function createRequest(?string $payload = null): ServerRequestInterface
415416

416417
private function createFactory(): McpServerFactory
417418
{
419+
$container = StaticContainer::getContainer();
420+
418421
return new McpServerFactory(
419422
$this->createMock(LoggerInterface::class),
420423
new InMemorySessionStore(),
421424
// Tool resolution now runs through the factory's container, so
422425
// happy-path tests need the real StaticContainer here. Tests that
423426
// never reach createServer() (guard rejections, auth failures) are
424427
// unaffected.
425-
StaticContainer::getContainer(),
428+
$container,
426429
new ToolCallParameterFormatter(),
430+
new McpToolsProvider($container),
427431
);
428432
}
429433

0 commit comments

Comments
 (0)