Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
92.65% |
189 / 204 |
|
66.67% |
2 / 3 |
CRAP | |
0.00% |
0 / 1 |
| RestApiCrud | |
92.65% |
189 / 204 |
|
66.67% |
2 / 3 |
43.74 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| derive_namespace | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
| register_abilities | |
92.31% |
180 / 195 |
|
0.00% |
0 / 1 |
37.62 | |||
| 1 | <?php |
| 2 | declare( strict_types=1 ); |
| 3 | |
| 4 | namespace BLU\Abilities; |
| 5 | |
| 6 | /** |
| 7 | * RestApiCrud abilities for generic WordPress REST API operations. |
| 8 | */ |
| 9 | class 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 | } |