Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.00% covered (success)
96.00%
240 / 250
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbilityGateway
96.00% covered (success)
96.00%
240 / 250
55.56% covered (warning)
55.56%
5 / 9
60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_whitelisted_abilities
92.68% covered (success)
92.68%
38 / 41
0.00% covered (danger)
0.00%
0 / 1
14.08
 get_whitelisted_ability
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 to_mcp_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_gateway_ability
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 normalize_parameters
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
9.29
 register_list_abilities
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
1 / 1
14
 register_get_ability_schema
97.78% covered (success)
97.78%
44 / 45
0.00% covered (danger)
0.00%
0 / 1
4
 register_call_ability
98.28% covered (success)
98.28%
57 / 58
0.00% covered (danger)
0.00%
0 / 1
13
1<?php
2
3declare( strict_types=1 );
4
5namespace BLU\Abilities;
6
7/**
8 * Gateway abilities that reduce ~83 individual MCP tools to 3 generic gateway tools.
9 *
10 * Instead of sending every tool's full JSON Schema to the LLM on each request,
11 * the gateway exposes only list/schema/call abilities. The LLM discovers tools
12 * on demand, dramatically reducing token usage.
13 */
14class AbilityGateway {
15
16    /**
17     * Canonical names of the gateway abilities (slash form).
18     *
19     * Used for registration, recursion guard, and list-abilities exclusion.
20     * Add new gateway abilities here — everything else derives from this list.
21     */
22    public const GATEWAY_ABILITIES = array(
23        'blu/list-abilities',
24        'blu/get-ability-schema',
25        'blu/call-ability',
26    );
27
28    /**
29     * Constructor — registers all gateway abilities.
30     */
31    public function __construct() {
32        $this->register_list_abilities();
33        $this->register_get_ability_schema();
34        $this->register_call_ability();
35    }
36
37    /* Whitelist helpers */
38
39    /**
40     * Returns the list of whitelisted abilities based on allowed namespaces and categories.
41     *
42     * @return \WP_Ability[] Filtered abilities.
43     */
44    private function get_whitelisted_abilities(): array {
45        $allowed_namespaces = apply_filters(
46            'blu_mcp_allowed_namespaces',
47            array(
48                'blu/',
49                'woocommerce/',
50            )
51        );
52
53        // Category gate — restricts the whitelist to abilities registered under
54        // these categories. Defaults to the BLU MCP category so the gateway
55        // only exposes the curated set; integrators can broaden via the filter.
56        $allowed_categories = apply_filters(
57            'blu_mcp_allowed_categories',
58            array(
59                'blu-mcp',
60            )
61        );
62
63        $all_abilities = blu_get_abilities();
64
65        return array_filter(
66            $all_abilities,
67            function ( $ability ) use ( $allowed_namespaces, $allowed_categories ) {
68                $name         = $ability->get_name();
69                $meta         = $ability->get_meta();
70                $ability_type = 'tool';
71                if ( isset( $meta['mcp']['type'] ) ) {
72                    $ability_type = $meta['mcp']['type'];
73                }
74                if ( 'tool' !== $ability_type ) {
75                    return false;
76                }
77
78                // Category gate: when the filter returns a non-empty list of
79                // strings, the ability's category must match one of them.
80                // Empty/non-array filter result disables the gate so callers
81                // can opt out via `return array();`.
82                if ( is_array( $allowed_categories ) && ! empty( $allowed_categories ) ) {
83                    $category    = $ability->get_category();
84                    $category_ok = false;
85                    foreach ( $allowed_categories as $cat ) {
86                        if ( is_string( $cat ) && '' !== $cat && $cat === $category ) {
87                            $category_ok = true;
88                            break;
89                        }
90                    }
91                    if ( ! $category_ok ) {
92                        return false;
93                    }
94                }
95
96                foreach ( $allowed_namespaces as $ns ) {
97                    // Skip empty/non-string entries — an empty string would match every
98                    // ability via str_starts_with() and silently bypass the whitelist.
99                    if ( ! is_string( $ns ) || '' === $ns ) {
100                        continue;
101                    }
102                    if ( str_starts_with( $name, $ns ) ) {
103                        return true;
104                    }
105                }
106
107                return false;
108            }
109        );
110    }
111
112    /**
113     * Checks whether a given ability name is whitelisted.
114     *
115     * Accepts both slash form (blu/posts-search) and hyphen form (blu-posts-search).
116     * Uses forward conversion (slash→hyphen) for matching, which is unambiguous
117     * unlike the reverse (hyphen→slash) which breaks for hyphenated namespaces.
118     *
119     * @param string $ability_name The ability name to check (either format).
120     *
121     * @return \WP_Ability|null The ability if whitelisted, null otherwise.
122     */
123    private function get_whitelisted_ability( string $ability_name ) {
124        $ability_name = trim( $ability_name );
125        $whitelisted  = $this->get_whitelisted_abilities();
126
127        foreach ( $whitelisted as $ability ) {
128            $name = $ability->get_name();
129            if ( $name === $ability_name || $this->to_mcp_name( $name ) === $ability_name ) {
130                return $ability;
131            }
132        }
133
134        return null;
135    }
136
137    /* Name conversion helpers */
138
139    /**
140     * Convert an internal ability name to the MCP tool name.
141     *
142     * Mirrors {@see RegisterAbilityAsMcpTool::get_data()}:
143     *   str_replace( '/', '-', trim( $ability->get_name() ) )
144     *
145     * @param string $name Ability name (slash form).
146     *
147     * @return string MCP tool name (hyphen form).
148     */
149    private function to_mcp_name( string $name ): string {
150        return str_replace( '/', '-', trim( $name ) );
151    }
152
153    /**
154     * Check whether an ability name (slash or hyphen form) is a gateway ability.
155     *
156     * @param string $name Ability name to check.
157     *
158     * @return bool True if it's a gateway ability.
159     */
160    private function is_gateway_ability( string $name ): bool {
161        $name = trim( $name );
162        foreach ( self::GATEWAY_ABILITIES as $gw ) {
163            if ( $gw === $name || $this->to_mcp_name( $gw ) === $name ) {
164                return true;
165            }
166        }
167
168        return false;
169    }
170
171    /* Parameter helpers */
172
173    /**
174     * Normalize delegated parameters for WP_Ability::execute().
175     *
176     * Abilities declare JSON Schema `type: object` for inputs. Passing null
177     * fails validation with "input is not of type object".
178     *
179     * @param mixed $parameters Raw parameters from the gateway call.
180     *
181     * @return array Associative array suitable as a JSON object payload.
182     */
183    private function normalize_parameters( $parameters ): array {
184        if ( null === $parameters ) {
185            return array();
186        }
187        if ( is_array( $parameters ) ) {
188            return $parameters;
189        }
190        if ( is_string( $parameters ) ) {
191            $decoded = json_decode( $parameters, true );
192            if ( is_array( $decoded ) ) {
193                return $decoded;
194            }
195        }
196
197        return array();
198    }
199
200    /* Gateway ability registration */
201
202    /**
203     * Register the blu/list-abilities gateway tool.
204     *
205     * Returns names, labels, descriptions, and annotations for all whitelisted abilities.
206     * Does NOT return input schemas to minimize token usage.
207     */
208    private function register_list_abilities(): void {
209        blu_register_ability(
210            'blu/list-abilities',
211            array(
212                'label'               => 'List Abilities',
213                'description'         => 'List the abilities available on this site. Each entry includes `name` (hyphen form), `label`, `description`, and `annotations`. Use the optional `search` and `name_prefix` filters to narrow the catalog.',
214                'category'            => 'blu-mcp',
215                'input_schema'        => array(
216                    'type'                 => 'object',
217                    'properties'           => array(
218                        'search'      => array(
219                            'type'        => 'string',
220                            'description' => 'Case-insensitive substring filter across each ability\'s name, label, and description.',
221                            'minLength'   => 1,
222                            'maxLength'   => 100,
223                        ),
224                        'name_prefix' => array(
225                            'type'        => 'string',
226                            'description' => 'Prefix match on the MCP tool name (hyphen form). Examples: `blu-wc-` for Bluehost WooCommerce wrappers, `woocommerce-` for WooCommerce-native abilities, `blu-posts` for post abilities. Slash form (e.g. `blu/wc`) is normalized to hyphen form.',
227                            'minLength'   => 1,
228                            'maxLength'   => 100,
229                            'pattern'     => '^[A-Za-z0-9/_-]+$',
230                        ),
231                    ),
232                    'additionalProperties' => false,
233                ),
234                'execute_callback'    => function ( $input = null ) {
235                    $abilities = $this->get_whitelisted_abilities();
236
237                    // Exclude gateway abilities — they are already exposed as
238                    // direct MCP tools via tools/list and cannot be called through
239                    // blu-call-ability, so listing them is redundant and confusing.
240                    $abilities = array_filter(
241                        $abilities,
242                        function ( $ability ) {
243                            return ! $this->is_gateway_ability( $ability->get_name() );
244                        }
245                    );
246
247                    $search      = isset( $input['search'] ) && is_string( $input['search'] ) ? trim( $input['search'] ) : '';
248                    $name_prefix = isset( $input['name_prefix'] ) && is_string( $input['name_prefix'] ) ? trim( $input['name_prefix'] ) : '';
249
250                    if ( '' !== $name_prefix ) {
251                        $name_prefix = rtrim( str_replace( '/', '-', $name_prefix ), '-' );
252                    }
253
254                    $abilities = array_filter(
255                        $abilities,
256                        function ( $ability ) use ( $search, $name_prefix ) {
257                            $mcp_name = $this->to_mcp_name( $ability->get_name() );
258
259                            if ( '' !== $name_prefix && 0 !== strpos( $mcp_name, $name_prefix ) ) {
260                                return false;
261                            }
262
263                            if ( '' !== $search ) {
264                                $haystacks = array(
265                                    $mcp_name,
266                                    (string) $ability->get_label(),
267                                    (string) $ability->get_description(),
268                                );
269                                $matched   = false;
270                                foreach ( $haystacks as $haystack ) {
271                                    if ( false !== mb_stripos( $haystack, $search ) ) {
272                                        $matched = true;
273                                        break;
274                                    }
275                                }
276                                if ( ! $matched ) {
277                                    return false;
278                                }
279                            }
280
281                            return true;
282                        }
283                    );
284
285                    $result = array();
286                    foreach ( $abilities as $ability ) {
287                        $meta        = $ability->get_meta();
288                        $annotations = isset( $meta['annotations'] ) ? $meta['annotations'] : array();
289
290                        $result[] = array(
291                            'name'        => $this->to_mcp_name( $ability->get_name() ),
292                            'label'       => $ability->get_label(),
293                            'description' => $ability->get_description(),
294                            'annotations' => $annotations,
295                        );
296                    }
297
298                    return blu_prepare_ability_response( 200, $result );
299                },
300                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
301                'meta'                => array(
302                    'annotations' => array(
303                        'readonly'    => true,
304                        'destructive' => false,
305                        'idempotent'  => true,
306                    ),
307                ),
308            )
309        );
310    }
311
312    /**
313     * Register the blu/get-ability-schema gateway ability.
314     *
315     * Returns the full input schema for a specific whitelisted ability so the LLM
316     * knows what parameters to pass when calling it.
317     */
318    private function register_get_ability_schema(): void {
319        blu_register_ability(
320            'blu/get-ability-schema',
321            array(
322                'label'               => 'Get Ability Schema',
323                'description'         => 'Get the full input schema for a specific ability, so the caller knows what parameters to pass when invoking it via blu-call-ability.',
324                'category'            => 'blu-mcp',
325                'input_schema'        => array(
326                    'type'       => 'object',
327                    'properties' => array(
328                        'ability_name' => array(
329                            'type'        => 'string',
330                            'description' => 'Ability name in hyphen form (e.g. "blu-posts-search", "woocommerce-products-list"), matching the `name` field from blu-list-abilities.',
331                        ),
332                    ),
333                    'required'   => array( 'ability_name' ),
334                ),
335                'execute_callback'    => function ( $input ) {
336                    if ( empty( $input['ability_name'] ) ) {
337                        return blu_prepare_ability_response( 400, 'The ability_name parameter is required.' );
338                    }
339
340                    $ability = $this->get_whitelisted_ability( $input['ability_name'] );
341
342                    if ( ! $ability ) {
343                        return blu_prepare_ability_response( 404, 'Ability not found or not available.' );
344                    }
345
346                    $meta        = $ability->get_meta();
347                    $annotations = isset( $meta['annotations'] ) ? $meta['annotations'] : array();
348
349                    return blu_prepare_ability_response(
350                        200,
351                        array(
352                            'name'         => $this->to_mcp_name( $ability->get_name() ),
353                            'label'        => $ability->get_label(),
354                            'description'  => $ability->get_description(),
355                            'input_schema' => $ability->get_input_schema(),
356                            'annotations'  => $annotations,
357                        )
358                    );
359                },
360                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
361                'meta'                => array(
362                    'annotations' => array(
363                        'readonly'    => true,
364                        'destructive' => false,
365                        'idempotent'  => true,
366                    ),
367                ),
368            )
369        );
370    }
371
372    /**
373     * Register the blu/call-ability gateway tool.
374     *
375     * Executes any whitelisted ability by name, delegating to the target ability's
376     * own permission_callback and execute_callback.
377     */
378    private function register_call_ability(): void {
379        blu_register_ability(
380            'blu/call-ability',
381            array(
382                'label'               => 'Call Ability',
383                'description'         => 'Execute an ability by name with its parameters. The gateway tools (blu-list-abilities, blu-get-ability-schema, blu-call-ability) cannot be invoked through this tool.',
384                'category'            => 'blu-mcp',
385                'input_schema'        => array(
386                    'type'       => 'object',
387                    'properties' => array(
388                        'ability_name' => array(
389                            'type'        => 'string',
390                            'description' => 'Ability name in hyphen form (e.g. "blu-posts-search"), matching the `name` field from blu-list-abilities.',
391                        ),
392                        'parameters'   => array(
393                            'type'        => 'object',
394                            'description' => 'Parameters object matching the ability\'s input_schema (see blu-get-ability-schema). Pass `{}` if the ability takes none.',
395                        ),
396                    ),
397                    'required'   => array( 'ability_name' ),
398                ),
399                'execute_callback'    => function ( $input ) {
400                    if ( empty( $input['ability_name'] ) ) {
401                        return blu_prepare_ability_response( 400, 'The ability_name parameter is required.' );
402                    }
403
404                    if ( $this->is_gateway_ability( $input['ability_name'] ) ) {
405                        return blu_prepare_ability_response(
406                            400,
407                            'Gateway tools cannot be called through blu-call-ability. Call them directly as MCP tools.'
408                        );
409                    }
410
411                    $ability = $this->get_whitelisted_ability( $input['ability_name'] );
412
413                    if ( ! $ability ) {
414                        return blu_prepare_ability_response( 404, 'Ability not found or not available.' );
415                    }
416
417                    $parameters = $this->normalize_parameters(
418                        isset( $input['parameters'] ) ? $input['parameters'] : null
419                    );
420
421                    $result = $ability->execute( $parameters );
422
423                    if ( is_wp_error( $result ) ) {
424                        // WordPress convention: WP_Error::get_error_code() is a string slug
425                        // (e.g. "rest_invalid_param") and the HTTP status lives in error_data.
426                        // Falling back to get_error_code() handles the rare case where an
427                        // ability returns WP_Error( 400, ... ) with an integer code.
428                        $error_data  = $result->get_error_data();
429                        $status_code = is_array( $error_data ) && isset( $error_data['status'] ) && is_int( $error_data['status'] )
430                            ? $error_data['status']
431                            : $result->get_error_code();
432
433                        if ( ! is_int( $status_code ) || $status_code < 400 || $status_code > 599 ) {
434                            $status_code = 500;
435                        }
436
437                        // Redact 5xx messages — abilities may surface stack frames,
438                        // SQL fragments, file paths, or upstream credentials in their
439                        // WP_Error message. 4xx messages are kept verbatim because
440                        // they carry validation/permission feedback the LLM needs to
441                        // self-correct.
442                        $message = $status_code >= 500
443                            ? 'Ability execution failed.'
444                            : $result->get_error_message();
445
446                        return blu_prepare_ability_response( $status_code, $message );
447                    }
448
449                    return $result;
450                },
451                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
452                'meta'                => array(
453                    'annotations' => array(
454                        'readonly'    => false,
455                        'destructive' => true,
456                        'idempotent'  => false,
457                    ),
458                ),
459            )
460        );
461    }
462}