1919use Piwik \API \Request as ApiRequest ;
2020use Piwik \Http \BadRequestException ;
2121use 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 ;
2427use Piwik \Plugins \McpServer \Support \Api \JsonRpcErrorResponseFactory ;
2528use Piwik \Plugins \McpServer \Support \Api \JsonRpcRequestIdExtractor ;
2629use 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 }
0 commit comments