Skip to content

Commit 8435128

Browse files
authored
Replace attribute-based tool registration with abstract McpTool base (#62)
* Replace attribute-based tool registration with abstract McpTool base * Collapse per-domain Detail+Summary schema classes into one * Hide vendored MCP SDK behind Matomo-owned tool contracts * Migrate plugin-internal throw sites to McpToolCallException * Refresh AGENTS.md for McpTool base and current layer layout
1 parent 527788f commit 8435128

102 files changed

Lines changed: 2239 additions & 1740 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ This document is repo-specific. Prefer it over generic Matomo assumptions when w
2828

2929
### MCP capability surface
3030

31-
- `McpTools/`: user-facing MCP tools. Start here for new capabilities or changes to tool behavior.
32-
- `Schemas/`: tool output schemas grouped by domain (`Sites`, `Reports`, `Goals`, `Segments`, `Dimensions`).
33-
- `Contracts/`: shared typed records and ports. Use these when a boundary needs an explicit typed contract.
31+
- `McpTools/`: user-facing MCP tools. Each tool is a class extending `Contracts\McpTool` with an `init()` that declares name/description/annotations/schemas and a public `execute(...)` method whose typed parameters define the input shape. Start here for new capabilities or changes to tool behavior.
32+
- `Schemas/`: tool input/output schemas grouped by domain (`Api`, `Sites`, `Reports`, `Goals`, `Segments`, `Dimensions`). Most domains expose a single combined `*ToolSchemas` class; `Api` and `Reports` still keep per-tool schema classes where the shape diverges between tools.
33+
- `Contracts/`: Matomo-owned types that bound the plugin's tool surface. Includes the `McpTool` base class, `McpToolAnnotations`, `McpToolIcon`, and `McpToolCallException`, plus shared typed records and ports under `Records/` and `Ports/`. Tool classes interact with these types only — they must not import the vendored MCP SDK directly.
3434

3535
### Domain and infrastructure layers
3636

@@ -75,7 +75,7 @@ Preferred flow:
7575

7676
### If the task changes domain behavior
7777

78-
Find the domain area under `Services/Sites`, `Services/Reports`, `Services/Goals`, `Services/Segments`, `Services/Dimensions`, or `Services/System`.
78+
Find the domain area under `Services/Api`, `Services/Sites`, `Services/Reports`, `Services/Goals`, `Services/Segments`, `Services/Dimensions`, or `Services/System`.
7979

8080
Before editing:
8181

@@ -87,10 +87,13 @@ Before editing:
8787

8888
### Adding new MCP capabilities
8989

90-
- Add user-facing capability through a tool class in `McpTools/`.
90+
- Add user-facing capability through a tool class in `McpTools/` that extends `Contracts\McpTool`. Implement `init()` to set name, description, annotations, input schema, and (when applicable) title, output schema, icons, and meta. Declare a public `execute(...)` method whose typed parameters define the JSON-RPC input shape.
91+
- Register the new tool class in `McpServerFactory::BUILTIN_TOOL_CLASSES` so the factory resolves it from the container.
92+
- Abort tool execution via `$this->fail($message)` from `execute()`; downstream services and helpers in the call chain may throw `McpToolCallException` directly when surfacing client-facing failures.
93+
- Interact only with Matomo-owned tool types (`McpTool`, `McpToolAnnotations`, `McpToolIcon`, `McpToolCallException`) inside `McpTools/`. Do not import or expose vendored MCP SDK types from tool classes — translation to the SDK happens centrally in `McpServerFactory`.
9194
- Keep Matomo/core access in focused services or gateway classes under `Services/`.
92-
- Define or update output schemas in `Schemas/`.
93-
- Use `Contracts/` only when a shared typed record or service boundary improves clarity.
95+
- Define or update tool schemas in `Schemas/`, matching the domain layout.
96+
- Use `Contracts/Records` and `Contracts/Ports` only when a shared typed record or service boundary improves clarity.
9497

9598
### Keeping boundaries clean
9699

Contracts/McpTool.php

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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\Contracts;
13+
14+
/**
15+
* Base class for MCP tools registered by the McpServer plugin.
16+
*
17+
* Each subclass represents exactly one MCP tool and MUST define a public
18+
* execute(...) method. Its typed parameters declare the tool's input shape
19+
* and receive the bound JSON-RPC arguments at call time. The method name is
20+
* intentionally fixed (no handlerMethod property) because there is no
21+
* remaining value in per-tool configurability.
22+
*
23+
* Subclasses interact only with Matomo-owned types (McpToolAnnotations,
24+
* McpToolIcon, the fail() helper).
25+
*/
26+
abstract class McpTool
27+
{
28+
protected string $name;
29+
protected string $description;
30+
protected McpToolAnnotations $annotations;
31+
protected ?string $title = null;
32+
33+
/** @var array<string, mixed> */
34+
protected array $inputSchema;
35+
36+
/** @var array<string, mixed>|null */
37+
protected ?array $outputSchema = null;
38+
39+
/** @var list<McpToolIcon>|null */
40+
protected ?array $icons = null;
41+
42+
/** @var array<string, mixed>|null */
43+
protected ?array $meta = null;
44+
45+
public function __construct()
46+
{
47+
// Placeholder defaults so static analysers can prove the typed properties are
48+
// initialised. Subclasses MUST overwrite the required ones inside init().
49+
$this->name = '';
50+
$this->description = '';
51+
$this->annotations = new McpToolAnnotations();
52+
$this->inputSchema = [];
53+
54+
$this->init();
55+
56+
if ($this->name === '') {
57+
throw new \LogicException(sprintf(
58+
'%s::init() must set $this->name to a non-empty tool name.',
59+
static::class,
60+
));
61+
}
62+
if ($this->inputSchema === []) {
63+
throw new \LogicException(sprintf(
64+
'%s::init() must set $this->inputSchema.',
65+
static::class,
66+
));
67+
}
68+
69+
// PHP cannot express "every subclass declares execute(...)" as an abstract
70+
// method because the SDK binds JSON-RPC arguments to typed parameters whose
71+
// signatures vary per tool — declaring abstract execute() with any concrete
72+
// signature would violate LSP for those overrides. A runtime check at boot
73+
// is the practical safety net; it fires the first time any tool is built.
74+
if (!method_exists($this, 'execute')) {
75+
throw new \LogicException(sprintf(
76+
'%s must define a public execute() method.',
77+
static::class,
78+
));
79+
}
80+
}
81+
82+
abstract protected function init(): void;
83+
84+
public function shouldRegister(): bool
85+
{
86+
return true;
87+
}
88+
89+
public function getName(): string
90+
{
91+
return $this->name;
92+
}
93+
94+
public function getDescription(): string
95+
{
96+
return $this->description;
97+
}
98+
99+
public function getAnnotations(): McpToolAnnotations
100+
{
101+
return $this->annotations;
102+
}
103+
104+
public function getTitle(): ?string
105+
{
106+
return $this->title;
107+
}
108+
109+
/**
110+
* @return array<string, mixed>
111+
*/
112+
public function getInputSchema(): array
113+
{
114+
return $this->inputSchema;
115+
}
116+
117+
/**
118+
* @return array<string, mixed>|null
119+
*/
120+
public function getOutputSchema(): ?array
121+
{
122+
return $this->outputSchema;
123+
}
124+
125+
/**
126+
* @return list<McpToolIcon>|null
127+
*/
128+
public function getIcons(): ?array
129+
{
130+
return $this->icons;
131+
}
132+
133+
/**
134+
* @return array<string, mixed>|null
135+
*/
136+
public function getMeta(): ?array
137+
{
138+
return $this->meta;
139+
}
140+
141+
/**
142+
* Abort execute() with a structured tool-call error returned to the
143+
* MCP client.
144+
*/
145+
protected function fail(string $message): never
146+
{
147+
throw new McpToolCallException($message);
148+
}
149+
}

Contracts/McpToolAnnotations.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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\Contracts;
13+
14+
/**
15+
* Behavioural hints attached to an McpTool, surfaced in tool listings so an
16+
* MCP client can reason about whether a call is read-only, destructive,
17+
* idempotent, or may reach beyond its declared inputs. Every field is
18+
* optional — null means "not declared".
19+
*/
20+
final class McpToolAnnotations
21+
{
22+
public function __construct(
23+
public readonly ?bool $readOnlyHint = null,
24+
public readonly ?bool $destructiveHint = null,
25+
public readonly ?bool $idempotentHint = null,
26+
public readonly ?bool $openWorldHint = null,
27+
) {
28+
}
29+
}

Contracts/McpToolCallException.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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\Contracts;
13+
14+
/**
15+
* Thrown to abort an MCP tool call with a structured error returned to the
16+
* MCP client. Typically raised from an McpTool subclass via $this->fail(),
17+
* but also thrown directly by supporting services and helpers (pagination,
18+
* gateways, normalisers, etc.) invoked during the tool's call chain.
19+
*/
20+
final class McpToolCallException extends \RuntimeException
21+
{
22+
}

Contracts/McpToolIcon.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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\Contracts;
13+
14+
/**
15+
* Optional icon advertised alongside an McpTool in tool listings. The src is
16+
* the only required field (a URL or data URI); mimeType narrows how the
17+
* client should decode it and sizes lists the available display dimensions
18+
* (e.g. "32x32").
19+
*/
20+
final class McpToolIcon
21+
{
22+
/**
23+
* @param list<string>|null $sizes
24+
*/
25+
public function __construct(
26+
public readonly string $src,
27+
public readonly ?string $mimeType = null,
28+
public readonly ?array $sizes = null,
29+
) {
30+
}
31+
}

0 commit comments

Comments
 (0)