ameliabooking
/
vendor
/
wordpress
/
mcp-adapter
/
includes
/
Domain
/
Tools
/
RegisterAbilityAsMcpTool.php
McpTool.php
3 months ago
McpToolValidator.php
3 months ago
RegisterAbilityAsMcpTool.php
3 months ago
RegisterAbilityAsMcpTool.php
242 lines
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * RegisterAbilityAsMcpTool class for converting WordPress abilities to MCP tools. |
| 5 | * |
| 6 | * @package McpAdapter |
| 7 | */ |
| 8 | |
| 9 | declare( strict_types=1 ); |
| 10 | |
| 11 | namespace WP\MCP\Domain\Tools; |
| 12 | |
| 13 | use WP\MCP\Domain\Utils\McpAnnotationMapper; |
| 14 | use WP\MCP\Domain\Utils\McpNameSanitizer; |
| 15 | use WP\MCP\Domain\Utils\McpValidator; |
| 16 | use WP\MCP\Domain\Utils\SchemaTransformer; |
| 17 | use WP\McpSchema\Server\Tools\DTO\Tool as ToolDto; |
| 18 | use WP_Error; |
| 19 | |
| 20 | /** |
| 21 | * RegisterAbilityAsMcpTool class. |
| 22 | * |
| 23 | * This class registers a WordPress ability as an MCP tool. |
| 24 | * |
| 25 | * @internal |
| 26 | * |
| 27 | * @package McpAdapter |
| 28 | */ |
| 29 | class RegisterAbilityAsMcpTool { |
| 30 | |
| 31 | /** |
| 32 | * The WordPress ability instance. |
| 33 | * |
| 34 | * @var \WP_Ability |
| 35 | */ |
| 36 | private \WP_Ability $ability; |
| 37 | |
| 38 | /** |
| 39 | * Constructor. |
| 40 | * |
| 41 | * @param \WP_Ability $ability The ability. |
| 42 | */ |
| 43 | private function __construct( \WP_Ability $ability ) { |
| 44 | $this->ability = $ability; |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * Build a clean Tool DTO and adapter metadata for internal wiring. |
| 49 | * |
| 50 | * This method returns a protocol-only Tool DTO and provides the adapter metadata |
| 51 | * separately. This keeps the DTO stable across MCP spec changes and avoids coupling internal execution |
| 52 | * wiring to protocol surfaces. |
| 53 | * |
| 54 | * @param \WP_Ability $ability The ability. |
| 55 | * |
| 56 | * @return array{tool: \WP\McpSchema\Server\Tools\DTO\Tool, adapter_meta: array<string, mixed>}|\WP_Error |
| 57 | * @since 0.5.0 |
| 58 | * |
| 59 | */ |
| 60 | public static function build( \WP_Ability $ability ) { |
| 61 | $tool = new self( $ability ); |
| 62 | $data = $tool->build_tool_data(); |
| 63 | |
| 64 | if ( is_wp_error( $data ) ) { |
| 65 | return $data; |
| 66 | } |
| 67 | |
| 68 | try { |
| 69 | $tool_dto = ToolDto::fromArray( $data['tool_data'] ); |
| 70 | } catch ( \Throwable $e ) { |
| 71 | return new WP_Error( |
| 72 | 'mcp_tool_dto_creation_failed', |
| 73 | sprintf( |
| 74 | /* translators: %s: error message */ |
| 75 | __( 'Failed to create Tool DTO for ability %1$s: %2$s', 'mcp-adapter' ), |
| 76 | $ability->get_name(), |
| 77 | $e->getMessage() |
| 78 | ), |
| 79 | array( 'exception' => $e ) |
| 80 | ); |
| 81 | } |
| 82 | |
| 83 | // Optional deep validation if enabled. |
| 84 | $mcp_validation_enabled = apply_filters( 'mcp_adapter_validation_enabled', false ); |
| 85 | if ( $mcp_validation_enabled ) { |
| 86 | $validation_result = McpToolValidator::validate_tool_dto( $tool_dto ); |
| 87 | if ( is_wp_error( $validation_result ) ) { |
| 88 | return $validation_result; |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | return array( |
| 93 | 'tool' => $tool_dto, |
| 94 | 'adapter_meta' => $data['adapter_meta'], |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Build Tool DTO data and adapter metadata. |
| 100 | * |
| 101 | * @return array{tool_data: array<string, mixed>, adapter_meta: array<string, mixed>}|\WP_Error |
| 102 | * @since 0.5.0 |
| 103 | * |
| 104 | */ |
| 105 | private function build_tool_data() { |
| 106 | // Resolve tool name first (can fail). |
| 107 | $tool_name = $this->resolve_tool_name(); |
| 108 | if ( is_wp_error( $tool_name ) ) { |
| 109 | return $tool_name; |
| 110 | } |
| 111 | |
| 112 | // Transform input schema to MCP-compatible object format. |
| 113 | $input_transform = SchemaTransformer::transform_to_object_schema( |
| 114 | $this->ability->get_input_schema() |
| 115 | ); |
| 116 | |
| 117 | $tool_data = array( |
| 118 | 'name' => $tool_name, |
| 119 | 'description' => trim( $this->ability->get_description() ), |
| 120 | 'inputSchema' => $input_transform['schema'], |
| 121 | ); |
| 122 | |
| 123 | // Add optional title from ability label. |
| 124 | $label = $this->ability->get_label(); |
| 125 | $label = trim( $label ); |
| 126 | if ( ! empty( $label ) ) { |
| 127 | $tool_data['title'] = $label; |
| 128 | } |
| 129 | |
| 130 | // Add optional output schema, transformed to object format if needed. |
| 131 | $output_schema = $this->ability->get_output_schema(); |
| 132 | $output_transform = null; |
| 133 | if ( ! empty( $output_schema ) ) { |
| 134 | $output_transform = SchemaTransformer::transform_to_object_schema( |
| 135 | $output_schema, |
| 136 | 'result' |
| 137 | ); |
| 138 | $tool_data['outputSchema'] = $output_transform['schema']; |
| 139 | } |
| 140 | |
| 141 | // Map annotations from ability meta to MCP format using unified mapper. |
| 142 | $ability_meta = $this->ability->get_meta(); |
| 143 | if ( ! empty( $ability_meta['annotations'] ) && is_array( $ability_meta['annotations'] ) ) { |
| 144 | $mcp_annotations = McpAnnotationMapper::map( $ability_meta['annotations'], 'tool' ); |
| 145 | if ( ! empty( $mcp_annotations ) ) { |
| 146 | $tool_data['annotations'] = $mcp_annotations; |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // Set annotations.title from label if annotations exist but don't have a title. |
| 151 | if ( ! empty( $label ) && isset( $tool_data['annotations'] ) && ! isset( $tool_data['annotations']['title'] ) ) { |
| 152 | $tool_data['annotations']['title'] = $label; |
| 153 | } |
| 154 | |
| 155 | // Store transformation metadata as internal metadata (stripped before responding to clients). |
| 156 | // Only record keys when semantically meaningful to keep metadata minimal and accurate. |
| 157 | $adapter_meta = array( |
| 158 | 'ability' => $this->ability->get_name(), |
| 159 | ); |
| 160 | |
| 161 | // Only record input transformation metadata when a wrapper was actually applied. |
| 162 | if ( ! empty( $input_transform['was_transformed'] ) ) { |
| 163 | $adapter_meta['input_schema_transformed'] = true; |
| 164 | $adapter_meta['input_schema_wrapper'] = $input_transform['wrapper_property']; |
| 165 | } |
| 166 | |
| 167 | // Only record output transformation metadata when outputSchema exists. |
| 168 | // Record wrapper only when transformation actually occurred. |
| 169 | if ( null !== $output_transform && ! empty( $output_transform['was_transformed'] ) ) { |
| 170 | $adapter_meta['output_schema_transformed'] = true; |
| 171 | $adapter_meta['output_schema_wrapper'] = $output_transform['wrapper_property']; |
| 172 | } |
| 173 | |
| 174 | // Map icons from ability.meta.mcp.icons if present. |
| 175 | $mcp_meta = $ability_meta['mcp'] ?? array(); |
| 176 | if ( ! empty( $mcp_meta['icons'] ) && is_array( $mcp_meta['icons'] ) ) { |
| 177 | $icons_result = McpValidator::validate_icons_array( $mcp_meta['icons'] ); |
| 178 | if ( ! empty( $icons_result['valid'] ) ) { |
| 179 | $tool_data['icons'] = $icons_result['valid']; |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // Build Tool `_meta`: |
| 184 | // - Preserve user-provided `_meta` from ability.meta.mcp._meta. |
| 185 | // - Adapter metadata is NEVER included in protocol DTO meta; it is returned separately in adapter_meta. |
| 186 | $tool_meta = array(); |
| 187 | if ( ! empty( $mcp_meta['_meta'] ) && is_array( $mcp_meta['_meta'] ) ) { |
| 188 | $tool_meta = $mcp_meta['_meta']; |
| 189 | } |
| 190 | if ( ! empty( $tool_meta ) ) { |
| 191 | $tool_data['_meta'] = $tool_meta; |
| 192 | } |
| 193 | |
| 194 | return array( |
| 195 | 'tool_data' => $tool_data, |
| 196 | 'adapter_meta' => $adapter_meta, |
| 197 | ); |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Resolve the MCP tool name from ability. |
| 202 | * |
| 203 | * Sanitizes the ability name to MCP-valid format, applies filter, and validates result. |
| 204 | * |
| 205 | * @return string|\WP_Error Valid tool name or error. |
| 206 | * @since 0.5.0 |
| 207 | * |
| 208 | */ |
| 209 | private function resolve_tool_name() { |
| 210 | // Sanitize ability name to MCP-valid format. |
| 211 | $sanitized_name = McpNameSanitizer::sanitize_name( $this->ability->get_name() ); |
| 212 | |
| 213 | if ( is_wp_error( $sanitized_name ) ) { |
| 214 | return $sanitized_name; |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Filters the MCP tool name derived from an ability. |
| 219 | * |
| 220 | * @since 0.5.0 |
| 221 | * |
| 222 | * @param string $name The sanitized tool name. |
| 223 | * @param \WP_Ability $ability The source ability instance. |
| 224 | */ |
| 225 | $filtered_name = apply_filters( 'mcp_adapter_tool_name', $sanitized_name, $this->ability ); |
| 226 | |
| 227 | // Validate post-filter (in case filter broke it). |
| 228 | if ( ! is_string( $filtered_name ) || ! McpValidator::validate_name( $filtered_name ) ) { |
| 229 | return new WP_Error( |
| 230 | 'mcp_tool_name_filter_invalid', |
| 231 | sprintf( |
| 232 | /* translators: %s: invalid tool name returned by filter */ |
| 233 | __( 'Filter returned invalid MCP tool name: %s', 'mcp-adapter' ), |
| 234 | is_string( $filtered_name ) ? $filtered_name : gettype( $filtered_name ) |
| 235 | ) |
| 236 | ); |
| 237 | } |
| 238 | |
| 239 | return $filtered_name; |
| 240 | } |
| 241 | } |
| 242 |