PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / lib / mcp-adapter / includes / Domain / Tools / McpTool.php

McpTool.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at lib/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