PluginProbe
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF / trunk
Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF vtrunk
2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.9 2.2.8 trunk 1.10 1.3.3 1.3.4 1.3.5 1.3.5.1 1.3.5.2 1.3.6 1.3.6.1 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.5 All 103 releases
imagify / vendor / wordpress / mcp-adapter / includes / Domain / Tools / McpTool.php

McpTool.php in Imagify Image Optimization: Optimize Images | Compress & Convert to WebP/AVIF trunk, at vendor/wordpress/mcp-adapter/includes/Domain/Tools/McpTool.php

417 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 as-is.
166 if ( isset( $config['meta'] ) && is_array( $config['meta'] ) && ! empty( $config['meta'] ) ) {
167 $tool_data['_meta'] = $config['meta'];
168 }
169
170 // Create the Tool DTO - wrap in try-catch since ToolAnnotations::fromArray() and ToolDto::fromArray() can throw.
171 try {
172 // Process annotations inside try-catch since ToolAnnotations::fromArray() can throw.
173 if ( isset( $config['annotations'] ) && is_array( $config['annotations'] ) && ! empty( $config['annotations'] ) ) {
174 $tool_data['annotations'] = ToolAnnotations::fromArray( $config['annotations'] );
175 }
176
177 $tool = ToolDto::fromArray( $tool_data );
178 } catch ( \Throwable $e ) {
179 return new WP_Error(
180 'mcp_tool_dto_creation_failed',
181 sprintf(
182 /* translators: %s: error message */
183 __( 'Failed to create Tool DTO: %s', 'mcp-adapter' ),
184 $e->getMessage()
185 ),
186 array( 'exception' => $e )
187 );
188 }
189
190 // Optional deep validation if enabled.
191 $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false );
192 if ( $mcp_validation_enabled ) {
193 $validation_result = McpToolValidator::validate_tool_dto( $tool );
194 if ( is_wp_error( $validation_result ) ) {
195 return $validation_result;
196 }
197 }
198
199 $instance = new self( $tool );
200 $instance->handler = $config['handler'];
201
202 if ( isset( $config['permission'] ) && is_callable( $config['permission'] ) ) {
203 $instance->permission_callback = $config['permission'];
204 }
205
206 $instance->observability_context = array(
207 'component_type' => 'tool',
208 'tool_name' => $config['name'],
209 'source' => 'array',
210 );
211
212 return $instance;
213 }
214
215 /**
216 * Create an ability-backed MCP tool.
217 *
218 * @param \WP_Ability $ability WordPress ability.
219 *
220 * @return self|\WP_Error
221 */
222 public static function fromAbility( \WP_Ability $ability ) {
223 $tool_data = RegisterAbilityAsMcpTool::build( $ability );
224 if ( $tool_data instanceof WP_Error ) {
225 return $tool_data;
226 }
227
228 $instance = new self( $tool_data['tool'] );
229 $instance->adapter_meta = $tool_data['adapter_meta'];
230 $instance->ability = $ability;
231
232 $instance->observability_context = array(
233 'component_type' => 'tool',
234 'tool_name' => $tool_data['tool']->getName(),
235 'ability_name' => $ability->get_name(),
236 'source' => 'ability',
237 );
238
239 return $instance;
240 }
241
242 // =========================================================================
243 // McpComponentInterface Implementation
244 // =========================================================================
245
246 /**
247 * Get the clean protocol DTO for MCP responses.
248 *
249 * @return \WP\McpSchema\Server\Tools\DTO\Tool
250 */
251 public function get_protocol_dto(): ToolDto {
252 return $this->tool;
253 }
254
255 /**
256 * Execute the tool.
257 *
258 * @param mixed $arguments Tool arguments.
259 *
260 * @return mixed
261 */
262 public function execute( $arguments ) {
263 $args = $this->unwrap_input_if_needed( $arguments );
264
265 if ( null !== $this->ability ) {
266 $args = AbilityArgumentNormalizer::normalize( $this->ability, $args );
267
268 try {
269 $result = $this->ability->execute( $args );
270 } catch ( \Throwable $throwable ) {
271 return new WP_Error(
272 'mcp_execution_failed',
273 $throwable->getMessage(),
274 array( 'error_type' => get_class( $throwable ) )
275 );
276 }
277 } elseif ( null !== $this->handler ) {
278 try {
279 $result = call_user_func( $this->handler, $args );
280 } catch ( \Throwable $throwable ) {
281 return new WP_Error(
282 'mcp_execution_failed',
283 $throwable->getMessage(),
284 array( 'error_type' => get_class( $throwable ) )
285 );
286 }
287 } else {
288 return new WP_Error( 'mcp_tool_no_handler', 'No tool execution strategy configured.' );
289 }
290
291 if ( $result instanceof WP_Error ) {
292 return $result;
293 }
294
295 $result = $this->wrap_output_if_needed( $result );
296
297 if ( ! is_array( $result ) ) {
298 $result = array( 'result' => $result );
299 }
300
301 return $result;
302 }
303
304 /**
305 * Unwrap tool input arguments when the input schema was transformed (flattened → object wrapper).
306 *
307 * @param mixed $arguments Raw tool arguments.
308 *
309 * @return mixed
310 */
311 private function unwrap_input_if_needed( $arguments ) {
312 $is_transformed = true === ( $this->adapter_meta['input_schema_transformed'] ?? false );
313
314 if ( ! $is_transformed ) {
315 return $arguments;
316 }
317
318 $wrapper = $this->adapter_meta['input_schema_wrapper'] ?? 'input';
319 $wrapper = is_string( $wrapper ) && '' !== trim( $wrapper ) ? $wrapper : 'input';
320
321 return is_array( $arguments ) ? ( $arguments[ $wrapper ] ?? null ) : null;
322 }
323
324 /**
325 * Wrap tool results when the output schema was transformed (flattened → object wrapper).
326 *
327 * @param mixed $result Raw result.
328 *
329 * @return mixed
330 */
331 private function wrap_output_if_needed( $result ) {
332 $is_transformed = true === ( $this->adapter_meta['output_schema_transformed'] ?? false );
333
334 if ( ! $is_transformed ) {
335 return $result;
336 }
337
338 $wrapper = $this->adapter_meta['output_schema_wrapper'] ?? 'result';
339 $wrapper = is_string( $wrapper ) && '' !== trim( $wrapper ) ? $wrapper : 'result';
340
341 return array( $wrapper => $result );
342 }
343
344 /**
345 * Check whether the current request has permission to execute this tool.
346 *
347 * @param mixed $arguments Tool arguments.
348 *
349 * @return bool|\WP_Error
350 */
351 public function check_permission( $arguments ) {
352 $args = $this->unwrap_input_if_needed( $arguments );
353
354 // Ability-backed tools delegate to the ability's permission system.
355 if ( null !== $this->ability ) {
356 $args = AbilityArgumentNormalizer::normalize( $this->ability, $args );
357
358 try {
359 return $this->ability->check_permissions( $args );
360 } catch ( \Throwable $throwable ) {
361 return new WP_Error(
362 'mcp_permission_check_failed',
363 $throwable->getMessage(),
364 array( 'error_type' => get_class( $throwable ) )
365 );
366 }
367 }
368
369 // Callable-backed tools use their required permission callback.
370 if ( null !== $this->permission_callback ) {
371 try {
372 $result = call_user_func( $this->permission_callback, $args );
373
374 return $result instanceof WP_Error ? $result : (bool) $result;
375 } catch ( \Throwable $throwable ) {
376 return new WP_Error(
377 'mcp_permission_check_failed',
378 $throwable->getMessage(),
379 array( 'error_type' => get_class( $throwable ) )
380 );
381 }
382 }
383
384 // Defensive fallback: should never reach here if factories are used correctly.
385 return new WP_Error(
386 'mcp_permission_denied',
387 'Access denied.',
388 array(
389 'failure_reason' => FailureReason::NO_PERMISSION_STRATEGY,
390 'tool_name' => $this->tool->getName(),
391 )
392 );
393 }
394
395 // =========================================================================
396 // Private Helper Methods
397 // =========================================================================
398
399 /**
400 * Get internal adapter metadata for this tool.
401 *
402 * @return array<string, mixed>
403 */
404 public function get_adapter_meta(): array {
405 return $this->adapter_meta;
406 }
407
408 /**
409 * Get observability context tags for logging/metrics.
410 *
411 * @return array<string, mixed>
412 */
413 public function get_observability_context(): array {
414 return $this->observability_context;
415 }
416 }
417