Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.65% covered (success)
92.65%
189 / 204
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
RestApiCrud
92.65% covered (success)
92.65%
189 / 204
66.67% covered (warning)
66.67%
2 / 3
43.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 derive_namespace
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 register_abilities
92.31% covered (success)
92.31%
180 / 195
0.00% covered (danger)
0.00%
0 / 1
37.62
1<?php
2declare( strict_types=1 );
3
4namespace BLU\Abilities;
5
6/**
7 * RestApiCrud abilities for generic WordPress REST API operations.
8 */
9class RestApiCrud {
10
11    /**
12     * Constructor - registers REST API CRUD abilities.
13     */
14    public function __construct() {
15        $this->register_abilities();
16    }
17
18    /**
19     * Derive the REST namespace for a route by matching against the set of namespaces
20     * actually registered with WordPress (longest prefix wins).
21     *
22     * WordPress REST namespaces may be single-segment (e.g. `wc-analytics`) or multi-segment
23     * (e.g. `wp/v2`, `wc/v3`, `wc-admin/marketing`). We cannot infer the boundary by counting
24     * segments — the truth lives in `WP_REST_Server::get_namespaces()`. The first call site
25     * fetches the list once and passes it in for every route to avoid repeated work.
26     *
27     * Examples (given `wp/v2`, `wc/v3`, `wc-analytics` are registered):
28     *   "/wp/v2/posts"                   → "wp/v2"
29     *   "/wc/v3/products/(?P<id>\d+)"    → "wc/v3"
30     *   "/wc-analytics"                  → "wc-analytics"
31     *   "/wc-analytics/reports/products" → "wc-analytics"
32     *   "/unknown-thing"                 → ""
33     *
34     * @param string   $route      Route path as registered with WordPress.
35     * @param string[] $namespaces Result of `WP_REST_Server::get_namespaces()`.
36     *
37     * @return string The longest matching registered namespace, or empty string if none match.
38     */
39    private function derive_namespace( string $route, array $namespaces ): string {
40        $route_trimmed = ltrim( $route, '/' );
41
42        $matched = '';
43        foreach ( $namespaces as $ns ) {
44            if ( $route_trimmed !== $ns && strpos( $route_trimmed, $ns . '/' ) !== 0 ) {
45                continue;
46            }
47            if ( strlen( $ns ) > strlen( $matched ) ) {
48                $matched = $ns;
49            }
50        }
51
52        return $matched;
53    }
54
55    /**
56     * Register REST API CRUD abilities.
57     */
58    private function register_abilities(): void {
59        // List available API functions
60        blu_register_ability(
61            'blu/list-api-functions',
62            array(
63                'label'               => 'List API Functions',
64                'description'         => 'List WordPress REST API endpoints registered on this site. Each item includes `route` (e.g. "/wp/v2/posts"), `method` (GET/POST/PATCH/DELETE), and `namespace` (the namespace WordPress registered the route under, e.g. "wp/v2" or "wc-analytics"). Use the optional `namespace`, `methods`, and `search` filters to narrow.',
65                'category'            => 'blu-mcp',
66                'input_schema'        => array(
67                    'type'                 => 'object',
68                    'properties'           => array(
69                        'namespace' => array(
70                            'type'        => 'string',
71                            'description' => 'REST namespace as registered with WordPress, e.g. "wp/v2", "wc/v3", "wc-analytics", "wc-admin/marketing". Single-segment namespaces (unversioned, e.g. "wc-analytics") and multi-segment namespaces are both supported. Leading and trailing slashes are tolerated.',
72                            'minLength'   => 1,
73                            'maxLength'   => 100,
74                            'pattern'     => '^/?[A-Za-z0-9_-]+(/[A-Za-z0-9_-]+)*/?$',
75                        ),
76                        'methods'   => array(
77                            'type'        => 'array',
78                            'description' => 'HTTP methods to include (uppercase). Omit or pass `[]` for all methods.',
79                            'items'       => array(
80                                'type' => 'string',
81                                'enum' => array( 'GET', 'POST', 'PATCH', 'DELETE' ),
82                            ),
83                            'uniqueItems' => true,
84                            'maxItems'    => 4,
85                        ),
86                        'search'    => array(
87                            'type'        => 'string',
88                            'description' => 'Case-insensitive substring filter on the route path.',
89                            'minLength'   => 1,
90                            'maxLength'   => 200,
91                        ),
92                    ),
93                    'additionalProperties' => false,
94                ),
95                'execute_callback'    => function ( $input = null ) {
96                    $ignore_routes  = array( '/', '/batch/v1', '/blu/mcp' );
97                    $ignore_strings = array( 'oembed', 'autosaves', 'revisions', 'jwt-auth' );
98
99                    $ns_filter = isset( $input['namespace'] ) && is_string( $input['namespace'] ) ? trim( $input['namespace'], " \t\n\r\0\x0B/" ) : '';
100
101                    $method_filter = array();
102                    if ( isset( $input['methods'] ) && is_array( $input['methods'] ) ) {
103                        foreach ( $input['methods'] as $m ) {
104                            if ( is_string( $m ) && '' !== $m ) {
105                                $method_filter[] = strtoupper( $m );
106                            }
107                        }
108                        $method_filter = array_values( array_unique( $method_filter ) );
109                    }
110
111                    $search_filter = isset( $input['search'] ) && is_string( $input['search'] ) ? trim( $input['search'] ) : '';
112
113                    // Force lazy-loaded REST namespaces to register before enumerating routes.
114                    // WC 10.3+ (and any plugin using the same pattern) attaches a `rest_pre_dispatch`
115                    // filter that only registers its namespace when the incoming request's route
116                    // starts with that namespace, or with `/` for discovery. Since our MCP request
117                    // is routed to `/blu/mcp`, those filters never fire and ~150+ routes (e.g.
118                    // `wc-analytics/*`) would be invisible to this catalog. Firing the filter
119                    // with a synthetic root request triggers the same discovery path WC uses for
120                    // `/wp-json/` calls. Skippable via the `blu_mcp_list_api_eager_load` filter
121                    // for sites that prefer the perf saving.
122                    if ( apply_filters( 'blu_mcp_list_api_eager_load', true ) ) {
123                        $root_request = new \WP_REST_Request( 'GET', '/' );
124                        apply_filters( 'rest_pre_dispatch', null, rest_get_server(), $root_request );
125                    }
126
127                    $server     = rest_get_server();
128                    $routes     = $server->get_routes();
129                    $namespaces = $server->get_namespaces();
130                    $result     = array();
131
132                    foreach ( $routes as $route => $endpoints ) {
133                        if ( in_array( $route, $ignore_routes, true ) ) {
134                            continue;
135                        }
136
137                        $skip = false;
138                        foreach ( $ignore_strings as $ignore_string ) {
139                            if ( strpos( $route, $ignore_string ) !== false ) {
140                                $skip = true;
141                                break;
142                            }
143                        }
144                        if ( $skip ) {
145                            continue;
146                        }
147
148                        $namespace = $this->derive_namespace( $route, $namespaces );
149
150                        if ( '' !== $ns_filter && $namespace !== $ns_filter ) {
151                            continue;
152                        }
153
154                        if ( '' !== $search_filter && false === mb_stripos( $route, $search_filter ) ) {
155                            continue;
156                        }
157
158                        // One endpoint definition can declare multiple HTTP methods
159                        // (e.g. WP_REST_Server::EDITABLE = "POST, PUT, PATCH").
160                        // Also, the same route can appear in multiple endpoint defs
161                        // — dedupe by (route, method) so the catalog is one row per pair.
162                        $emitted = array();
163                        foreach ( $endpoints as $endpoint ) {
164                            if ( empty( $endpoint['methods'] ) || ! is_array( $endpoint['methods'] ) ) {
165                                continue;
166                            }
167                            foreach ( array_keys( $endpoint['methods'] ) as $method ) {
168                                if ( isset( $emitted[ $method ] ) ) {
169                                    continue;
170                                }
171                                if ( ! empty( $method_filter ) && ! in_array( $method, $method_filter, true ) ) {
172                                    continue;
173                                }
174                                $emitted[ $method ] = true;
175
176                                $result[] = array(
177                                    'route'     => $route,
178                                    'method'    => $method,
179                                    'namespace' => $namespace,
180                                );
181                            }
182                        }
183                    }
184
185                    return blu_prepare_ability_response( 200, $result );
186                },
187                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
188                'meta'                => array(
189                    'annotations' => array(
190                        'readonly'    => true,
191                        'destructive' => false,
192                        'idempotent'  => true,
193                    ),
194                ),
195            )
196        );
197
198        // Get function details
199        blu_register_ability(
200            'blu/get-function-details',
201            array(
202                'label'               => 'Get Function Details',
203                'description'         => 'Return the endpoint metadata (args schema, methods, accept_json, etc.) for one route + method pair, so the caller knows what parameters the endpoint accepts.',
204                'category'            => 'blu-mcp',
205                'input_schema'        => array(
206                    'type'       => 'object',
207                    'properties' => array(
208                        'route'  => array(
209                            'type'        => 'string',
210                            'description' => 'REST API route, including the leading slash (e.g. "/wp/v2/posts", "/wp/v2/posts/(?P<id>[\\d]+)"). Matches the `route` field from blu-list-api-functions.',
211                        ),
212                        'method' => array(
213                            'type'        => 'string',
214                            'enum'        => array( 'GET', 'POST', 'PATCH', 'DELETE' ),
215                            'description' => 'HTTP method (uppercase).',
216                        ),
217                    ),
218                    'required'   => array( 'route', 'method' ),
219                ),
220                'execute_callback'    => function ( $input ) {
221                    $route  = $input['route'];
222                    $method = $input['method'];
223
224                    $routes = rest_get_server()->get_routes();
225
226                    if ( ! isset( $routes[ $route ] ) ) {
227                        return blu_prepare_ability_response( 404, 'Route not found' );
228                    }
229
230                    foreach ( $routes[ $route ] as $endpoint ) {
231                        if ( isset( $endpoint['methods'][ $method ] ) ) {
232                            // Strip callable references — they JSON-encode as null (closures)
233                            // or as a bare class-name pair, neither of which is useful to the
234                            // caller and the latter leaks internal class names. The LLM only
235                            // needs the `args` schema and the `methods` map.
236                            unset( $endpoint['callback'], $endpoint['permission_callback'] );
237                            return blu_prepare_ability_response( 200, $endpoint );
238                        }
239                    }
240
241                    return blu_prepare_ability_response( 404, 'Method not found for this route' );
242                },
243                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
244                'meta'                => array(
245                    'annotations' => array(
246                        'readonly'    => true,
247                        'destructive' => false,
248                        'idempotent'  => true,
249                    ),
250                ),
251            )
252        );
253
254        // Run API function
255        blu_register_ability(
256            'blu/run-api-function',
257            array(
258                'label'               => 'Run API Function',
259                'description'         => 'Execute a WordPress REST API endpoint by route, method, and data. For routes with path parameters (e.g. "/wp/v2/posts/(?P<id>[\\d]+)"), substitute concrete values into the route (e.g. "/wp/v2/posts/42").',
260                'category'            => 'blu-mcp',
261                'input_schema'        => array(
262                    'type'       => 'object',
263                    'properties' => array(
264                        'route'  => array(
265                            'type'        => 'string',
266                            'description' => 'REST API route, including the leading slash (e.g. "/wp/v2/posts").',
267                        ),
268                        'method' => array(
269                            'type'        => 'string',
270                            'enum'        => array( 'GET', 'POST', 'PATCH', 'DELETE' ),
271                            'description' => 'HTTP method (uppercase).',
272                        ),
273                        'data'   => array(
274                            'type'        => 'object',
275                            'description' => 'Request parameters matching the endpoint\'s args schema (see blu-get-function-details). Sent as query params for GET/DELETE, body for POST/PATCH.',
276                        ),
277                    ),
278                    'required'   => array( 'route', 'method' ),
279                ),
280                'execute_callback'    => function ( $input ) {
281                    $route  = $input['route'];
282                    $method = $input['method'];
283                    $data   = $input['data'] ?? array();
284
285                    // Parse query parameters from route if present
286                    $query_params = array();
287                    if ( strpos( $route, '?' ) !== false ) {
288                        $parts      = explode( '?', $route, 2 );
289                        $route      = $parts[0];
290                        parse_str( $parts[1], $query_params );
291                    }
292
293                    // Refuse to re-enter the MCP transport from inside an ability.
294                    // Dispatching to the MCP route would let a crafted call invoke
295                    // this same ability with route=/blu/mcp again — a self-loop with
296                    // no legitimate use case.
297                    if ( '/blu/mcp' === $route || 0 === strpos( $route, '/blu/mcp/' ) ) {
298                        return blu_prepare_ability_response( 400, 'Refusing to dispatch to the MCP transport route from within an ability.' );
299                    }
300
301                    // Create REST request
302                    $request = new \WP_REST_Request( $method, $route );
303
304                    // Set parameters based on method
305                    if ( in_array( $method, array( 'GET', 'DELETE' ), true ) ) {
306                        if ( ! empty( $data ) ) {
307                            $query_params = array_merge( $query_params, $data );
308                        }
309                        if ( ! empty( $query_params ) ) {
310                            $request->set_query_params( $query_params );
311                        }
312                    } elseif ( ! empty( $data ) ) {
313                        $request->set_body_params( $data );
314                    }
315
316                    $response = rest_do_request( $request );
317                    return blu_standardize_rest_response( $response );
318                },
319                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
320                'meta'                => array(
321                    'annotations' => array(
322                        'readonly'    => false,
323                        'destructive' => true,
324                        'idempotent'  => false,
325                    ),
326                ),
327            )
328        );
329    }
330}