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 / Domain / Tools / McpTool.php

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

418 lines 11.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * MCP Tool component.
5 *
6 * @package McpAdapter
7 */
8
9 declare( strict_types=1 );
10
11 namespace WP\MCP\Domain\Tools;
12
13 use WP\MCP\Domain\Contracts\McpComponentInterface;
14 use WP\MCP\Domain\Utils\AbilityArgumentNormalizer;
15 use WP\MCP\Domain\Utils\McpValidator;
16 use WP\MCP\Infrastructure\Observability\FailureReason;
17 use WP\McpSchema\Server\Tools\DTO\Tool as ToolDto;
18 use WP\McpSchema\Server\Tools\DTO\ToolAnnotations;
19 use WP_Error;
20
21 /**
22 * Tool component providing unified execution and permission checks.
23 *
24 * This class provides multiple flexible ways to create MCP tools:
25 *
26 * 1. Array configuration:
27 * ```php
28 * $tool = McpTool::fromArray([
29 * 'name' => 'uppercase-text',
30 * 'title' => 'Uppercase Text',
31 * 'description' => 'Converts text to uppercase',
32 * 'inputSchema' => ['type' => 'object', 'properties' => [...]],
33 * 'handler' => fn($args) => ['result' => strtoupper($args['text'])],
34 * 'permission' => fn() => true,
35 * 'annotations' => ['readOnlyHint' => true],
36 * ]);
37 * ```
38 *
39 * 2. From WordPress Ability (ability-backed):
40 * ```php
41 * $tool = McpTool::fromAbility($ability);
42 * ```
43 *
44 * McpTool wraps a protocol-only ToolDto for MCP serialization. Internal
45 * adapter metadata and execution wiring live on this class and are never
46 * exposed to MCP clients. Use get_protocol_dto() for protocol responses.
47 *
48 * @since 0.5.0
49 */
50 final class McpTool implements McpComponentInterface {
51
52
53 // =========================================================================
54 // Runtime Properties
55 // =========================================================================
56
57 /**
58 * Clean Tool DTO (protocol-only).
59 *
60 * @var \WP\McpSchema\Server\Tools\DTO\Tool
61 */
62 private ToolDto $tool;
63
64 /**
65 * Ability used for execution/permission checks (ability-backed tools).
66 *
67 * @var \WP_Ability|null
68 */
69 private ?\WP_Ability $ability = null;
70
71 /**
72 * Direct execution handler (callable-backed tools).
73 *
74 * @var callable|null
75 */
76 private $handler = null;
77
78 /**
79 * Direct permission callback (callable-backed tools).
80 *
81 * @var callable|null
82 */
83 private $permission_callback = null;
84
85 /**
86 * Internal adapter metadata (never exposed to clients).
87 *
88 * @var array<string, mixed>
89 */
90 private array $adapter_meta = array();
91
92 /**
93 * Observability context tags for logging/metrics.
94 *
95 * @var array<string, mixed>
96 */
97 private array $observability_context = array();
98
99 // =========================================================================
100 // Constructor
101 // =========================================================================
102
103 /**
104 * Private constructor - use factory methods.
105 *
106 * @param \WP\McpSchema\Server\Tools\DTO\Tool $tool The Tool DTO.
107 */
108 private function __construct( ToolDto $tool ) {
109 $this->tool = $tool;
110 }
111
112 // =========================================================================
113 // Factory Methods
114 // =========================================================================
115
116 /**
117 * Create a tool definition from an array configuration.
118 *
119 * @param array $config The tool configuration array.
120 *
121 * @return self|\WP_Error
122 */
123 public static function fromArray( array $config ) {
124 if ( empty( $config['name'] ) ) {
125 return new WP_Error( 'mcp_tool_missing_name', 'Tool configuration must include a "name" field.' );
126 }
127
128 if ( ! isset( $config['handler'] ) || ! is_callable( $config['handler'] ) ) {
129 return new WP_Error( 'mcp_tool_missing_handler', 'Tool configuration must include a callable "handler" field.' );
130 }
131
132 // Prepare input schema - ensure it's an object type for MCP compliance.
133 $input_schema = $config['inputSchema'] ?? array( 'type' => 'object' );
134 if ( ! isset( $input_schema['type'] ) ) {
135 $input_schema['type'] = 'object';
136 }
137
138 // Build tool data array.
139 $tool_data = array(
140 'name' => $config['name'],
141 'inputSchema' => $input_schema,
142 );
143
144 // Optional fields.
145 if ( isset( $config['title'] ) ) {
146 $tool_data['title'] = $config['title'];
147 }
148
149 if ( isset( $config['description'] ) ) {
150 $tool_data['description'] = $config['description'];
151 }
152
153 if ( isset( $config['outputSchema'] ) && is_array( $config['outputSchema'] ) ) {
154 $tool_data['outputSchema'] = $config['outputSchema'];
155 }
156
157 // Validate and prepare icons if set.
158 if ( isset( $config['icons'] ) && is_array( $config['icons'] ) && ! empty( $config['icons'] ) ) {
159 $icons_result = McpValidator::validate_icons_array( $config['icons'] );
160 if ( ! empty( $icons_result['valid'] ) ) {
161 $tool_data['icons'] = $icons_result['valid'];
162 }
163 }
164
165 // Preserve user-provided _meta.
166 $tool_meta = McpValidator::normalize_meta( $config['meta'] ?? null );
167 if ( null !== $tool_meta ) {
168 $tool_data['_meta'] = $tool_meta;
169 }
170
171 // Create the Tool DTO - wrap in try-catch since ToolAnnotations::fromArray() and ToolDto::fromArray() can throw.
172 try {
173 // Process annotations inside try-catch since ToolAnnotations::fromArray() can throw.
174 if ( isset( $config['annotations'] ) && is_array( $config['annotations'] ) && ! empty( $config['annotations'] ) ) {
175 $tool_data['annotations'] = ToolAnnotations::fromArray( $config['annotations'] );
176 }
177
178 $tool = ToolDto::fromArray( $tool_data );
179 } catch ( \Throwable $e ) {
180 return new WP_Error(
181 'mcp_tool_dto_creation_failed',
182 sprintf(
183 /* translators: %s: error message */
184 __( 'Failed to create Tool DTO: %s', 'mcp-adapter' ),
185 $e->getMessage()
186 ),
187 array( 'exception' => $e )
188 );
189 }
190
191 // Optional deep validation if enabled.
192 $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false );
193 if ( $mcp_validation_enabled ) {
194 $validation_result = McpToolValidator::validate_tool_dto( $tool );
195 if ( is_wp_error( $validation_result ) ) {
196 return $validation_result;
197 }
198 }
199
200 $instance = new self( $tool );
201 $instance->handler = $config['handler'];
202
203 if ( isset( $config['permission'] ) && is_callable( $config['permission'] ) ) {
204 $instance->permission_callback = $config['permission'];
205 }
206
207 $instance->observability_context = array(
208 'component_type' => 'tool',
209 'tool_name' => $config['name'],
210 'source' => 'array',
211 );
212
213 return $instance;
214 }
215
216 /**
217 * Create an ability-backed MCP tool.
218 *
219 * @param \WP_Ability $ability WordPress ability.
220 *
221 * @return self|\WP_Error
222 */
223 public static function fromAbility( \WP_Ability $ability ) {
224 $tool_data = RegisterAbilityAsMcpTool::build( $ability );
225 if ( $tool_data instanceof WP_Error ) {
226 return $tool_data;
227 }
228
229 $instance = new self( $tool_data['tool'] );
230 $instance->adapter_meta = $tool_data['adapter_meta'];
231 $instance->ability = $ability;
232
233 $instance->observability_context = array(
234 'component_type' => 'tool',
235 'tool_name' => $tool_data['tool']->getName(),
236 'ability_name' => $ability->get_name(),
237 'source' => 'ability',
238 );
239
240 return $instance;
241 }
242
243 // =========================================================================
244 // McpComponentInterface Implementation
245 // =========================================================================
246
247 /**
248 * Get the clean protocol DTO for MCP responses.
249 *
250 * @return \WP\McpSchema\Server\Tools\DTO\Tool
251 */
252 public function get_protocol_dto(): ToolDto {
253 return $this->tool;
254 }
255
256 /**
257 * Execute the tool.
258 *
259 * @param mixed $arguments Tool arguments.
260 *
261 * @return mixed
262 */
263 public function execute( $arguments ) {
264 $args = $this->unwrap_input_if_needed( $arguments );
265
266 if ( null !== $this->ability ) {
267 $args = AbilityArgumentNormalizer::normalize( $this->ability, $args );
268
269 try {
270 $result = $this->ability->execute( $args );
271 } catch ( \Throwable $throwable ) {
272 return new WP_Error(
273 'mcp_execution_failed',
274 $throwable->getMessage(),
275 array( 'error_type' => get_class( $throwable ) )
276 );
277 }
278 } elseif ( null !== $this->handler ) {
279 try {
280 $result = call_user_func( $this->handler, $args );
281 } catch ( \Throwable $throwable ) {
282 return new WP_Error(
283 'mcp_execution_failed',
284 $throwable->getMessage(),
285 array( 'error_type' => get_class( $throwable ) )
286 );
287 }
288 } else {
289 return new WP_Error( 'mcp_tool_no_handler', 'No tool execution strategy configured.' );
290 }
291
292 if ( $result instanceof WP_Error ) {
293 return $result;
294 }
295
296 $result = $this->wrap_output_if_needed( $result );
297
298 if ( ! is_array( $result ) ) {
299 $result = array( 'result' => $result );
300 }
301
302 return $result;
303 }
304
305 /**
306 * Unwrap tool input arguments when the input schema was transformed (flattened → object wrapper).
307 *
308 * @param mixed $arguments Raw tool arguments.
309 *
310 * @return mixed
311 */
312 private function unwrap_input_if_needed( $arguments ) {
313 $is_transformed = true === ( $this->adapter_meta['input_schema_transformed'] ?? false );
314
315 if ( ! $is_transformed ) {
316 return $arguments;
317 }
318
319 $wrapper = $this->adapter_meta['input_schema_wrapper'] ?? 'input';
320 $wrapper = is_string( $wrapper ) && '' !== trim( $wrapper ) ? $wrapper : 'input';
321
322 return is_array( $arguments ) ? ( $arguments[ $wrapper ] ?? null ) : null;
323 }
324
325 /**
326 * Wrap tool results when the output schema was transformed (flattened → object wrapper).
327 *
328 * @param mixed $result Raw result.
329 *
330 * @return mixed
331 */
332 private function wrap_output_if_needed( $result ) {
333 $is_transformed = true === ( $this->adapter_meta['output_schema_transformed'] ?? false );
334
335 if ( ! $is_transformed ) {
336 return $result;
337 }
338
339 $wrapper = $this->adapter_meta['output_schema_wrapper'] ?? 'result';
340 $wrapper = is_string( $wrapper ) && '' !== trim( $wrapper ) ? $wrapper : 'result';
341
342 return array( $wrapper => $result );
343 }
344
345 /**
346 * Check whether the current request has permission to execute this tool.
347 *
348 * @param mixed $arguments Tool arguments.
349 *
350 * @return bool|\WP_Error
351 */
352 public function check_permission( $arguments ) {
353 $args = $this->unwrap_input_if_needed( $arguments );
354
355 // Ability-backed tools delegate to the ability's permission system.
356 if ( null !== $this->ability ) {
357 $args = AbilityArgumentNormalizer::normalize( $this->ability, $args );
358
359 try {
360 return $this->ability->check_permissions( $args );
361 } catch ( \Throwable $throwable ) {
362 return new WP_Error(
363 'mcp_permission_check_failed',
364 $throwable->getMessage(),
365 array( 'error_type' => get_class( $throwable ) )
366 );
367 }
368 }
369
370 // Callable-backed tools use their required permission callback.
371 if ( null !== $this->permission_callback ) {
372 try {
373 $result = call_user_func( $this->permission_callback, $args );
374
375 return $result instanceof WP_Error ? $result : (bool) $result;
376 } catch ( \Throwable $throwable ) {
377 return new WP_Error(
378 'mcp_permission_check_failed',
379 $throwable->getMessage(),
380 array( 'error_type' => get_class( $throwable ) )
381 );
382 }
383 }
384
385 // Defensive fallback: should never reach here if factories are used correctly.
386 return new WP_Error(
387 'mcp_permission_denied',
388 'Access denied.',
389 array(
390 'failure_reason' => FailureReason::NO_PERMISSION_STRATEGY,
391 'tool_name' => $this->tool->getName(),
392 )
393 );
394 }
395
396 // =========================================================================
397 // Private Helper Methods
398 // =========================================================================
399
400 /**
401 * Get internal adapter metadata for this tool.
402 *
403 * @return array<string, mixed>
404 */
405 public function get_adapter_meta(): array {
406 return $this->adapter_meta;
407 }
408
409 /**
410 * Get observability context tags for logging/metrics.
411 *
412 * @return array<string, mixed>
413 */
414 public function get_observability_context(): array {
415 return $this->observability_context;
416 }
417 }
418