Skip to content

Commit e975083

Browse files
authored
Add in-process tool catalogue and call API for other plugins (#64)
1 parent 656649c commit e975083

20 files changed

Lines changed: 1803 additions & 165 deletions

API.php

Lines changed: 75 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@
1919
use Piwik\API\Request as ApiRequest;
2020
use Piwik\Http\BadRequestException;
2121
use Piwik\NoAccessException;
22-
use Piwik\Piwik;
23-
use Piwik\Plugins\McpServer\Support\Access\McpAccessLevel;
22+
use Piwik\Plugins\McpServer\Support\Access\McpAccessGate;
23+
use Piwik\Plugins\McpServer\Support\Access\McpUnavailableException;
24+
use Piwik\Plugins\McpServer\Support\Api\InternalApiAccessGuard;
25+
use Piwik\Plugins\McpServer\Support\Api\InternalToolCaller;
26+
use Piwik\Plugins\McpServer\Support\Api\InternalToolCatalog;
2427
use Piwik\Plugins\McpServer\Support\Api\JsonRpcErrorResponseFactory;
2528
use Piwik\Plugins\McpServer\Support\Api\JsonRpcRequestIdExtractor;
2629
use Piwik\Plugins\McpServer\Support\Api\McpEndpointGuard;
@@ -34,7 +37,10 @@ public function __construct(
3437
private McpEndpointGuard $endpointGuard,
3538
private JsonRpcErrorResponseFactory $jsonRpcErrorResponseFactory,
3639
private JsonRpcRequestIdExtractor $jsonRpcRequestIdExtractor,
37-
private SystemSettings $systemSettings,
40+
private McpAccessGate $accessGate,
41+
private InternalApiAccessGuard $internalAccessGuard,
42+
private InternalToolCatalog $internalToolCatalog,
43+
private InternalToolCaller $internalToolCaller,
3844
) {
3945
}
4046

@@ -79,8 +85,9 @@ public function mcp(): ResponseInterface
7985
}
8086

8187
try {
82-
$this->checkUserIsNotAnonymous();
83-
$this->checkUserHasSomeViewAccess();
88+
$this->accessGate->assertHttp();
89+
} catch (McpUnavailableException | BadRequestException $e) {
90+
return $this->createForbiddenResponse($requestMetadata['topLevelRequestId'], $e->getMessage());
8491
} catch (\Throwable $e) {
8592
if ($this->isUnauthorizedLike($e)) {
8693
return $this->createUnauthorizedResponse($requestId);
@@ -94,14 +101,6 @@ public function mcp(): ResponseInterface
94101
);
95102
}
96103

97-
if (!$this->isMcpEnabled()) {
98-
return $this->createDisabledResponse($requestMetadata['topLevelRequestId']);
99-
}
100-
101-
if (!$this->isCurrentUserPrivilegeLevelAllowed()) {
102-
return $this->createPrivilegeTooHighResponse($requestMetadata['topLevelRequestId']);
103-
}
104-
105104
try {
106105
$server = $this->factory->createServer();
107106
$transport = new StreamableHttpTransport($request);
@@ -117,19 +116,68 @@ public function mcp(): ResponseInterface
117116
}
118117
}
119118

120-
protected function createRequestFromGlobals(): ServerRequestInterface
119+
/**
120+
* Returns the registered MCP tool catalogue for in-process consumers.
121+
*
122+
* Throws {@see \Piwik\NoAccessException} when the caller is anonymous or
123+
* lacks view access, and {@see McpUnavailableException} when MCP is
124+
* disabled site-wide; consumers can branch on the latter to surface a
125+
* "feature off" message instead of a generic access error.
126+
*
127+
* @internal
128+
* @return list<array{
129+
* name: string,
130+
* title: string|null,
131+
* description: string,
132+
* inputSchema: array<string, mixed>,
133+
* outputSchema: array<string, mixed>|null,
134+
* readOnly: bool|null,
135+
* destructive: bool|null,
136+
* idempotent: bool|null,
137+
* openWorld: bool|null
138+
* }> Registered MCP tools with schemas and behaviour hints.
139+
*/
140+
public function getInternalToolCatalog(): array
121141
{
122-
return (new Psr17Factory())->createServerRequestFromGlobals();
142+
$this->internalAccessGuard->assertInternalContext();
143+
$this->accessGate->assertBase();
144+
145+
return $this->internalToolCatalog->build($this->factory->createInternalAccess());
123146
}
124147

125-
protected function checkUserHasSomeViewAccess(): void
148+
/**
149+
* Calls a registered MCP tool for in-process consumers.
150+
*
151+
* Tool and protocol failures are returned as an `isError` payload; context
152+
* and access failures throw as ordinary Matomo API exceptions
153+
* ({@see \Piwik\NoAccessException} for anonymous / no-view callers,
154+
* {@see McpUnavailableException} when MCP is disabled site-wide).
155+
*
156+
* @internal
157+
* @unsanitized
158+
* @param string $name Registered MCP tool name.
159+
* @param array<string, mixed> $arguments Arguments validated against the tool input schema.
160+
* @return array{
161+
* content: list<array<string, mixed>>,
162+
* structuredContent: array<string, mixed>|null,
163+
* isError: bool
164+
* } Serialized MCP tool result payload.
165+
*/
166+
public function callInternalTool(string $name, array $arguments = []): array
126167
{
127-
Piwik::checkUserHasSomeViewAccess();
168+
$this->internalAccessGuard->assertInternalContext();
169+
$this->accessGate->assertBase();
170+
171+
return $this->internalToolCaller->call(
172+
$this->factory->createInternalAccess(),
173+
$name,
174+
$arguments,
175+
);
128176
}
129177

130-
protected function checkUserIsNotAnonymous(): void
178+
protected function createRequestFromGlobals(): ServerRequestInterface
131179
{
132-
Piwik::checkUserIsNotAnonymous();
180+
return (new Psr17Factory())->createServerRequestFromGlobals();
133181
}
134182

135183
protected function createUnauthorizedResponse(string|int $requestId = ''): ResponseInterface
@@ -143,19 +191,6 @@ protected function createUnauthorizedResponse(string|int $requestId = ''): Respo
143191
);
144192
}
145193

146-
protected function isMcpEnabled(): bool
147-
{
148-
return $this->systemSettings->isMcpEnabled();
149-
}
150-
151-
protected function isCurrentUserPrivilegeLevelAllowed(): bool
152-
{
153-
return !McpAccessLevel::exceedsMaximumAllowed(
154-
McpAccessLevel::resolveCurrentUserLevel(),
155-
$this->systemSettings->getMaximumAllowedMcpAccessLevel(),
156-
);
157-
}
158-
159194
protected function isCurrentApiRequestRoot(): bool
160195
{
161196
return ApiRequest::isCurrentApiRequestTheRootApiRequest();
@@ -181,21 +216,13 @@ private function isUnauthorizedLike(\Throwable $e): bool
181216
return false;
182217
}
183218

184-
protected function createDisabledResponse(string|int|null $topLevelRequestId): ResponseInterface
185-
{
186-
if ($topLevelRequestId === null) {
187-
return (new Psr17Factory())->createResponse(403);
188-
}
189-
190-
return $this->jsonRpcErrorResponseFactory->create(
191-
403,
192-
JsonRpcError::INVALID_REQUEST,
193-
McpEndpointSpec::DISABLED_ERROR,
194-
$topLevelRequestId,
195-
);
196-
}
197-
198-
protected function createPrivilegeTooHighResponse(string|int|null $topLevelRequestId): ResponseInterface
219+
/**
220+
* Shape a 403 response for the HTTP mcp() path when {@see McpAccessGate::assertHttp()}
221+
* rejected the request via {@see McpUnavailableException} (MCP disabled) or
222+
* {@see BadRequestException} (privilege ceiling exceeded). The exception's
223+
* message is the canonical text.
224+
*/
225+
protected function createForbiddenResponse(string|int|null $topLevelRequestId, string $message): ResponseInterface
199226
{
200227
if ($topLevelRequestId === null) {
201228
return (new Psr17Factory())->createResponse(403);
@@ -204,7 +231,7 @@ protected function createPrivilegeTooHighResponse(string|int|null $topLevelReque
204231
return $this->jsonRpcErrorResponseFactory->create(
205232
403,
206233
JsonRpcError::INVALID_REQUEST,
207-
McpAccessLevel::createTooHighPrivilegeMessage($this->systemSettings->getMaximumAllowedMcpAccessLevel()),
234+
$message,
208235
$topLevelRequestId,
209236
);
210237
}

McpServerFactory.php

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Matomo\Dependencies\McpServer\Mcp\Schema\ToolAnnotations;
1919
use Matomo\Dependencies\McpServer\Mcp\Server;
2020
use Matomo\Dependencies\McpServer\Mcp\Server\Builder;
21+
use Matomo\Dependencies\McpServer\Mcp\Server\Handler\Request\RequestHandlerInterface;
2122
use Matomo\Dependencies\McpServer\Mcp\Server\Session\SessionStoreInterface;
2223
use Piwik\Config;
2324
use Piwik\Log\LoggerInterface;
@@ -46,6 +47,7 @@
4647
use Piwik\Plugins\McpServer\McpTools\SiteSearch;
4748
use Piwik\Plugins\McpServer\Server\Handler\Request\CompatibleCallToolHandler;
4849
use Piwik\Plugins\McpServer\Server\Handler\Request\ObservedCallToolHandler;
50+
use Piwik\Plugins\McpServer\Server\InternalAccess;
4951
use Piwik\Plugins\McpServer\Support\Logging\ToolCallParameterFormatter;
5052
use Psr\Container\ContainerInterface;
5153
use Psr\Log\NullLogger;
@@ -84,6 +86,9 @@ final class McpServerFactory
8486
SiteSearch::class,
8587
];
8688

89+
/** @var array{server: Server, registry: Registry, callToolHandler: RequestHandlerInterface<mixed>}|null */
90+
private ?array $runtimeCache = null;
91+
8792
public function __construct(
8893
private LoggerInterface $logger,
8994
private SessionStoreInterface $sessionStore,
@@ -97,8 +102,73 @@ public function __construct(
97102
* the factory resolves the shipped tool
98103
* set via its container. Tests inject an
99104
* explicit list to bypass DI.
105+
* An explicit list bypasses the runtime
106+
* cache and always builds a fresh server;
107+
* it is never written back to the cache,
108+
* so production calls (which pass null)
109+
* keep seeing the shipped tool set.
100110
*/
101111
public function createServer(?array $tools = null): Server
112+
{
113+
if ($tools !== null) {
114+
return $this->buildRuntime($tools)['server'];
115+
}
116+
117+
return $this->resolveRuntime()['server'];
118+
}
119+
120+
public function createInternalAccess(): InternalAccess
121+
{
122+
$runtime = $this->resolveRuntime();
123+
124+
return new InternalAccess(
125+
$runtime['registry'],
126+
$runtime['callToolHandler'],
127+
);
128+
}
129+
130+
/**
131+
* Discard the memoised runtime so the next {@see createServer} or
132+
* {@see createInternalAccess} call rebuilds against the current settings.
133+
*
134+
* The factory is a container singleton, so the cached runtime lives for the
135+
* whole PHP process — one request under PHP-FPM, but potentially many logical
136+
* operations in a long-running CLI worker. Production code still does not need
137+
* to clear it: MCP settings and tool registration are stable for the process
138+
* lifetime, and per-call state is isolated separately by the fresh in-memory
139+
* session {@see InternalToolCaller} builds on every call. Tests that toggle
140+
* settings affecting tool registration between builds must call this to drop
141+
* the now-stale runtime.
142+
*
143+
* @internal
144+
*/
145+
public function clearRuntimeCache(): void
146+
{
147+
$this->runtimeCache = null;
148+
}
149+
150+
/**
151+
* Memoise the MCP runtime so callers that hit the factory multiple times in
152+
* one request (e.g. {@see getInternalToolCatalog} followed by repeated
153+
* {@see callInternalTool} dispatches) reuse the same registry and handler
154+
* chain.
155+
*
156+
* @return array{server: Server, registry: Registry, callToolHandler: RequestHandlerInterface<mixed>}
157+
*/
158+
private function resolveRuntime(): array
159+
{
160+
if ($this->runtimeCache !== null) {
161+
return $this->runtimeCache;
162+
}
163+
164+
return $this->runtimeCache = $this->buildRuntime();
165+
}
166+
167+
/**
168+
* @param list<McpTool>|null $tools
169+
* @return array{server: Server, registry: Registry, callToolHandler: RequestHandlerInterface<mixed>}
170+
*/
171+
private function buildRuntime(?array $tools = null): array
102172
{
103173
$tools ??= $this->buildBuiltinTools();
104174

@@ -140,19 +210,25 @@ public function createServer(?array $tools = null): Server
140210
new NullLogger(),
141211
);
142212

213+
$activeCallToolHandler = $callToolHandler;
143214
if ($loggingConfig['logToolCalls']) {
144-
$builder->addRequestHandler(new ObservedCallToolHandler(
215+
$activeCallToolHandler = new ObservedCallToolHandler(
145216
$callToolHandler,
146217
$this->logger,
147218
$this->toolCallParameterFormatter,
148219
$loggingConfig['logFullParameters'],
149220
$loggingConfig['logLevel'],
150-
));
151-
} else {
152-
$builder->addRequestHandler($callToolHandler);
221+
);
153222
}
223+
$builder->addRequestHandler($activeCallToolHandler);
224+
225+
$server = $builder->build();
154226

155-
return $builder->build();
227+
return [
228+
'server' => $server,
229+
'registry' => $registry,
230+
'callToolHandler' => $activeCallToolHandler,
231+
];
156232
}
157233

158234
private function registerTool(Builder $builder, McpTool $tool): void

Server/Handler/Request/ObservedCallToolHandler.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Matomo\Dependencies\McpServer\Mcp\Server\Handler\Request\RequestHandlerInterface;
2121
use Matomo\Dependencies\McpServer\Mcp\Server\Session\SessionInterface;
2222
use Piwik\Log\LoggerInterface;
23+
use Piwik\Plugins\McpServer\Support\Logging\McpToolCallOrigin;
2324
use Piwik\Plugins\McpServer\Support\Logging\ToolCallParameterFormatter;
2425

2526
/**
@@ -59,6 +60,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er
5960
'mcp_session_id' => $this->resolveSessionId($session),
6061
'mcp_tool_name' => $toolName,
6162
'mcp_params_mode' => $loggingMode,
63+
McpToolCallOrigin::LOG_CONTEXT_KEY => $this->resolveCallOrigin($session),
6264
];
6365

6466
$result = $this->delegate->handle($request, $session);
@@ -144,6 +146,21 @@ private function resolveSessionId(SessionInterface $session): string
144146
}
145147
}
146148

149+
private function resolveCallOrigin(SessionInterface $session): string
150+
{
151+
try {
152+
$origin = $session->get(McpToolCallOrigin::SESSION_KEY, McpToolCallOrigin::ORIGIN_HTTP);
153+
} catch (\Throwable) {
154+
return McpToolCallOrigin::ORIGIN_HTTP;
155+
}
156+
157+
if ($origin === McpToolCallOrigin::ORIGIN_INTERNAL) {
158+
return McpToolCallOrigin::ORIGIN_INTERNAL;
159+
}
160+
161+
return McpToolCallOrigin::ORIGIN_HTTP;
162+
}
163+
147164
/**
148165
* @param Response<mixed> $response
149166
*/

Server/InternalAccess.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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\Server;
13+
14+
use Matomo\Dependencies\McpServer\Mcp\Capability\RegistryInterface;
15+
use Matomo\Dependencies\McpServer\Mcp\Server\Handler\Request\RequestHandlerInterface;
16+
17+
/**
18+
* Internal handle on the populated MCP runtime exposed to other Matomo
19+
* plugins via API.php. Holds the same registry and call-tool handler that
20+
* back the public HTTP endpoint, so internal in-process callers see the
21+
* exact same tool catalogue and execution semantics.
22+
*
23+
* Deliberately does not expose the production session store: internal callers
24+
* dispatch a single tool per call with an ephemeral in-memory session, so a
25+
* persistent session store would only be a footgun if a tool ever called
26+
* Session::save().
27+
*
28+
* Not part of any public SDK contract; safe to evolve alongside this plugin.
29+
* The constructor-promoted readonly properties are likewise internal and may
30+
* be reshaped without notice.
31+
*
32+
* @internal
33+
*/
34+
final class InternalAccess
35+
{
36+
/**
37+
* @param RequestHandlerInterface<mixed> $callToolHandler
38+
*/
39+
public function __construct(
40+
public readonly RegistryInterface $registry,
41+
public readonly RequestHandlerInterface $callToolHandler,
42+
) {
43+
}
44+
}

0 commit comments

Comments
 (0)