PluginProbe
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages / 3.4.3
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages v3.4.3
3.4.3 3.4.2 3.4.1 3.4.0 3.3.9 3.3.8 3.3.7 3.3.6 3.3.5 3.3.4 3.3.3 3.3.2 3.3.1 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.2.7 2.2.8 2.2.9 2.3.0 2.3.1 All 196 releases
convertkit / vendor / wordpress / mcp-adapter / includes / Handlers / Tools / ToolsHandler.php

ToolsHandler.php in Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages 3.4.3, at vendor/wordpress/mcp-adapter/includes/Handlers/Tools/ToolsHandler.php

384 lines 12.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Tools method handlers for MCP requests.
4 *
5 * @package McpAdapter
6 */
7
8 declare( strict_types=1 );
9
10 namespace WP\MCP\Handlers\Tools;
11
12 use WP\MCP\Core\McpServer;
13 use WP\MCP\Domain\Utils\ContentBlockHelper;
14 use WP\MCP\Domain\Utils\McpValidator;
15 use WP\MCP\Handlers\HandlerHelperTrait;
16 use WP\MCP\Infrastructure\ErrorHandling\McpErrorFactory;
17 use WP\MCP\Infrastructure\Observability\FailureReason;
18 use WP\McpSchema\Server\Tools\DTO\CallToolResult;
19 use WP\McpSchema\Server\Tools\DTO\ListToolsResult;
20
21 /**
22 * Handles tools-related MCP methods.
23 */
24 class ToolsHandler {
25 use HandlerHelperTrait;
26
27 /**
28 * Default MIME type for image results when none is specified.
29 *
30 * @var string
31 */
32 private const DEFAULT_IMAGE_MIME_TYPE = 'image/png';
33
34 /**
35 * The WordPress MCP instance.
36 *
37 * @var \WP\MCP\Core\McpServer
38 */
39 private McpServer $mcp;
40
41 /**
42 * Constructor.
43 *
44 * @param \WP\MCP\Core\McpServer $mcp The WordPress MCP instance.
45 */
46 public function __construct( McpServer $mcp ) {
47 $this->mcp = $mcp;
48 }
49
50 /**
51 * Handles the tools/list/all request.
52 *
53 * This is a custom extension to the MCP spec that includes availability status.
54 * Returns a ListToolsResult DTO containing all registered tools.
55 *
56 * Note: The 'available' flag is a non-standard extension and is not currently implemented.
57 *
58 * @return \WP\McpSchema\Server\Tools\DTO\ListToolsResult Response with all tools.
59 */
60 public function list_all_tools(): ListToolsResult {
61 // Return the standard tools list.
62 return $this->list_tools();
63 }
64
65 /**
66 * Handles the tools/list request.
67 *
68 * Returns a ListToolsResult DTO containing all registered tools.
69 * Tool DTOs are protocol-only; internal adapter metadata is stored in McpTool instances and is never exposed
70 * to MCP clients.
71 *
72 * @return \WP\McpSchema\Server\Tools\DTO\ListToolsResult Response with tools list.
73 */
74 public function list_tools(): ListToolsResult {
75 $tools = array_values( $this->mcp->get_tools() );
76
77 /**
78 * Filters the list of tools before returning to the client.
79 *
80 * Use this filter to hide tools per user/role, add dynamic tools,
81 * or reorder the tools list.
82 *
83 * @since 0.5.0
84 *
85 * @param array<\WP\McpSchema\Server\Tools\DTO\Tool> $tools Array of Tool DTOs.
86 * @param \WP\MCP\Core\McpServer $server The MCP server instance.
87 */
88 $tools = $this->validate_filtered_list(
89 apply_filters( 'mcp_adapter_tools_list', $tools, $this->mcp ),
90 $tools,
91 'mcp_adapter_tools_list',
92 $this->mcp->get_error_handler()
93 );
94
95 return ListToolsResult::fromArray(
96 array(
97 'tools' => $tools,
98 )
99 );
100 }
101
102 /**
103 * Handles the tools/call request.
104 *
105 * Returns either a CallToolResult DTO (for success or tool execution errors)
106 * or a JSONRPCErrorResponse DTO (for protocol errors like tool not found).
107 *
108 * The MCP spec distinguishes between:
109 * 1. **Protocol errors** (tool not found, server error) → JSONRPCErrorResponse
110 * 2. **Tool execution errors** (permission denied, runtime error) → CallToolResult with isError=true
111 *
112 * This distinction is critical for LLM self-correction - execution errors are
113 * visible to the LLM, while protocol errors indicate infrastructure issues.
114 *
115 * @param array $params Request params.
116 * @param string|int|null $request_id Optional. The request ID for JSON-RPC. Default 0.
117 *
118 * @return \WP\McpSchema\Server\Tools\DTO\CallToolResult|\WP\McpSchema\Common\JsonRpc\DTO\JSONRPCErrorResponse
119 */
120 public function call_tool( array $params, $request_id = 0 ) {
121 // Extract parameters using helper method.
122 $request_params = $this->extract_params( $params );
123
124 if ( ! isset( $request_params['name'] ) ) {
125 return McpErrorFactory::missing_parameter( $request_id, 'tool name' );
126 }
127
128 if ( isset( $request_params['arguments'] ) && ! is_array( $request_params['arguments'] ) ) {
129 return McpErrorFactory::invalid_params( $request_id, 'arguments must be an object' );
130 }
131
132 try {
133 $tool_name = trim( (string) $request_params['name'] );
134 $args = $request_params['arguments'] ?? array();
135
136 $mcp_tool = $this->mcp->get_mcp_tool( $tool_name );
137 if ( ! $mcp_tool ) {
138 $this->mcp->get_error_handler()->log(
139 'Tool not found',
140 array(
141 'tool_name' => $tool_name,
142 ),
143 'warning'
144 );
145
146 return McpErrorFactory::tool_not_found( $request_id, $tool_name );
147 }
148
149 $permission = $mcp_tool->check_permission( $args );
150 if ( true !== $permission ) {
151 $error_message = __( 'Permission denied', 'mcp-adapter' );
152 if ( is_wp_error( $permission ) ) {
153 $error_message = $permission->get_error_message();
154
155 $this->mcp->get_error_handler()->log(
156 'Tool permission check failed',
157 array(
158 'tool_name' => $tool_name,
159 'error_code' => $permission->get_error_code(),
160 'error_message' => $permission->get_error_message(),
161 'error_data' => $permission->get_error_data(),
162 'failure_reason' => FailureReason::PERMISSION_CHECK_FAILED,
163 )
164 );
165 }
166
167 return $this->create_error_result( $error_message );
168 }
169
170 /**
171 * Filters tool arguments before execution, or short-circuits execution entirely.
172 *
173 * Return the (optionally modified) arguments array to proceed with execution,
174 * or return a WP_Error to block execution and return an error to the client.
175 *
176 * @since 0.5.0
177 *
178 * @param array $args The tool arguments.
179 * @param string $tool_name The tool name being called.
180 * @param \WP\MCP\Domain\Tools\McpTool $mcp_tool The MCP tool instance.
181 * @param \WP\MCP\Core\McpServer $server The MCP server instance.
182 */
183 $args = apply_filters( 'mcp_adapter_pre_tool_call', $args, $tool_name, $mcp_tool, $this->mcp );
184
185 // Allow pre-filter to short-circuit execution by returning WP_Error.
186 if ( is_wp_error( $args ) ) {
187 return $this->create_error_result( $args->get_error_message() );
188 }
189
190 $result = $mcp_tool->execute( $args );
191
192 /**
193 * Filters the tool execution result before response assembly.
194 *
195 * Use this filter for result transformation, PII redaction,
196 * audit logging, or content enrichment.
197 *
198 * @since 0.5.0
199 *
200 * @param mixed|\WP_Error $result The raw execution result (may be WP_Error).
201 * @param array $args The tool arguments used.
202 * @param string $tool_name The tool name that was called.
203 * @param \WP\MCP\Domain\Tools\McpTool $mcp_tool The MCP tool instance.
204 * @param \WP\MCP\Core\McpServer $server The MCP server instance.
205 */
206 $result = apply_filters( 'mcp_adapter_tool_call_result', $result, $args, $tool_name, $mcp_tool, $this->mcp );
207
208 if ( is_wp_error( $result ) ) {
209 $this->mcp->get_error_handler()->log(
210 'Tool execution returned WP_Error',
211 array(
212 'tool_name' => $tool_name,
213 'error_code' => $result->get_error_code(),
214 'error_message' => $result->get_error_message(),
215 'error_data' => $result->get_error_data(),
216 )
217 );
218
219 return $this->create_error_result( $result->get_error_message() );
220 }
221
222 // Backward compatibility: treat `{ success: false, error: string }` as tool execution error.
223 if (
224 is_array( $result )
225 && array_key_exists( 'success', $result )
226 && false === $result['success']
227 && isset( $result['error'] )
228 && is_string( $result['error'] )
229 && '' !== trim( $result['error'] )
230 ) {
231 return $this->create_error_result( $result['error'] );
232 }
233
234 // Successful tool execution - build CallToolResult DTO.
235
236 // Handle embedded resource results (MCP ContentBlock type: "resource").
237 // This allows tools to return text/blob resources using the MCP schema's EmbeddedResource content block.
238 //
239 // Two shapes are accepted, and they place `_meta` differently:
240 //
241 // - Nested `{ type, resource: { uri, text, _meta }, _meta }` maps one-to-one onto
242 // the DTO tree, so the outer `_meta` belongs to the content block and the inner
243 // one to the resource contents.
244 // - Flat `{ type, uri, mimeType, text, _meta }` is a resource-contents literal
245 // carrying a `type` tag: every key beside `type` is a `ResourceContents` field,
246 // and `_meta` is declared there alongside them. Its `_meta` therefore describes
247 // the resource, which is what the same literal already means to
248 // `ResourcesHandler::create_content_dto()`. A caller who needs block-level
249 // `_meta` writes the nested form, which exists to express that distinction.
250 if ( isset( $result['type'] ) && 'resource' === $result['type'] ) {
251 $is_nested = isset( $result['resource'] ) && is_array( $result['resource'] );
252 $resource_item = $is_nested ? $result['resource'] : $result;
253
254 $uri = $resource_item['uri'] ?? null;
255 $mime_type = $resource_item['mimeType'] ?? null;
256
257 if ( is_string( $uri ) ) {
258 $uri = trim( $uri );
259 }
260
261 // Only return an EmbeddedResource if we have a valid URI and some content.
262 $has_text = isset( $resource_item['text'] ) && is_string( $resource_item['text'] );
263 $has_blob = isset( $resource_item['blob'] ) && is_string( $resource_item['blob'] );
264
265 if ( is_string( $uri ) && '' !== $uri && ( $has_text || $has_blob ) ) {
266 $block_meta = $is_nested
267 ? McpValidator::normalize_meta( $result['_meta'] ?? null )
268 : null;
269 $resource_meta = McpValidator::normalize_meta( $resource_item['_meta'] ?? null );
270
271 if ( $has_text ) {
272 return CallToolResult::fromArray(
273 array(
274 'content' => array(
275 ContentBlockHelper::embedded_text_resource(
276 $uri,
277 $resource_item['text'],
278 is_string( $mime_type ) ? $mime_type : null,
279 null,
280 $block_meta,
281 $resource_meta
282 ),
283 ),
284 'isError' => false,
285 )
286 );
287 }
288
289 if ( $has_blob ) {
290 return CallToolResult::fromArray(
291 array(
292 'content' => array(
293 ContentBlockHelper::embedded_blob_resource(
294 $uri,
295 $resource_item['blob'],
296 is_string( $mime_type ) ? $mime_type : null,
297 null,
298 $block_meta,
299 $resource_meta
300 ),
301 ),
302 'isError' => false,
303 )
304 );
305 }
306 }
307 }
308
309 // Handle image results.
310 //
311 // `type` marks this result as a description of a content block rather than tool
312 // data, so its sibling `_meta` is the block's, which is the reading the `resource`
313 // branch above already applies to the same key.
314 if ( isset( $result['type'] ) && 'image' === $result['type'] && isset( $result['results'] ) ) {
315 $image_data = base64_encode( $result['results'] ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
316 $mime_type = $result['mimeType'] ?? self::DEFAULT_IMAGE_MIME_TYPE;
317
318 return CallToolResult::fromArray(
319 array(
320 'content' => array(
321 ContentBlockHelper::image(
322 $image_data,
323 $mime_type,
324 null,
325 McpValidator::normalize_meta( $result['_meta'] ?? null )
326 ),
327 ),
328 'structuredContent' => null,
329 'isError' => false,
330 )
331 );
332 }
333
334 // The generic fallback carries no `type` marker, so every key it holds is tool
335 // data: the result is JSON-encoded into the text block and returned verbatim as
336 // `structuredContent`. Reading `_meta` off it would give one key two meanings,
337 // with nothing to tell metadata from a domain field.
338
339 // Standard result - JSON-encode for text content, include as structuredContent.
340 $json_text = wp_json_encode( $result );
341 if ( false === $json_text ) {
342 $json_text = '{}';
343 }
344
345 return CallToolResult::fromArray(
346 array(
347 'content' => array( ContentBlockHelper::text( $json_text ) ),
348 'structuredContent' => $result,
349 'isError' => false,
350 )
351 );
352 } catch ( \Throwable $exception ) {
353 $this->mcp->get_error_handler()->log(
354 'Error calling tool',
355 array(
356 'tool' => $request_params['name'],
357 'exception' => $exception->getMessage(),
358 )
359 );
360
361 return McpErrorFactory::internal_error( $request_id, 'Failed to execute tool' );
362 }
363 }
364
365 /**
366 * Create an error CallToolResult from a message string.
367 *
368 * @since 0.5.0
369 *
370 * @param string $message The error message.
371 *
372 * @return \WP\McpSchema\Server\Tools\DTO\CallToolResult
373 */
374 private function create_error_result( string $message ): CallToolResult {
375 return CallToolResult::fromArray(
376 array(
377 'content' => array( ContentBlockHelper::text( $message ) ),
378 'structuredContent' => null,
379 'isError' => true,
380 )
381 );
382 }
383 }
384