PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / trunk
ZIP AI – AI Website Builder & AI Agent (Beta) vtrunk
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / core / execute-rest-request.php

execute-rest-request.php in ZIP AI – AI Website Builder & AI Agent (Beta) trunk, at inc/abilities/core/execute-rest-request.php

623 lines 18.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Execute REST Request Ability
4 *
5 * Generic REST proxy via rest_do_request(). WordPress handles ALL auth/permissions
6 * via each route's permission_callback.
7 *
8 * @package zip-ai
9 */
10
11 namespace ZipAI\MCP\Classes\Abilities\Core;
12
13 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
14 use ZipAI\MCP\Classes\Core\RouteSchemaBuilder;
15 use ZipAI\MCP\Classes\Core\Tool_Types;
16 use ZipAI\MCP\Classes\Core\Response;
17 use ZipAI\MCP\Classes\Core\Utils;
18
19 // Exit if accessed directly.
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 /**
25 * Class ExecuteRestRequest
26 */
27 class ExecuteRestRequest extends Abstract_Ability {
28
29 /**
30 * Maximum batch size (matches WP core rest_get_max_batch_size()).
31 *
32 * @var int
33 */
34 const MAX_BATCH_SIZE = 25;
35
36 /**
37 * Maximum response items to prevent context window overflow.
38 *
39 * @var int
40 */
41 const MAX_RESPONSE_ITEMS = 100;
42
43 /**
44 * Is destructive.
45 *
46 * @var bool
47 */
48 protected $is_destructive = true;
49
50 /**
51 * Every WordPress read and write the agent performs funnels through this one
52 * proxy — a multi-page website build spends hundreds of calls in a minute
53 * (page creates, style guide, templates, chrome, meta, site defaults), so the
54 * interactive default would throttle a legitimate build mid-flight.
55 *
56 * @var int
57 */
58 protected $rate_limit = 1000;
59
60 /**
61 * Configure the ability.
62 */
63 public function configure() {
64 $this->id = 'zipai/run-rest-request';
65 $this->label = 'Execute REST API Request';
66 $this->description = 'The primary tool for reading and writing WordPress data via the REST API. '
67 . 'Supports GET, POST, PUT, PATCH, DELETE against any registered route — posts, pages, users, '
68 . 'terms, plugins, settings, media, custom post types, and plugin-registered routes. '
69 . 'WordPress enforces its own permission_callback per route. '
70 . 'Supports batching up to 25 requests in a single call. '
71 . 'Use search-endpoints to discover route names and required params before calling.';
72 // WordPress REST endpoints enforce their own permission_callback per-route.
73 // This tool is a generic proxy — use edit_posts (same as other zipwp tools).
74 $this->capability = 'edit_posts';
75
76 // Hidden from the LLM tool list but kept registered for internal
77 // server-side callers. The server filters tools where
78 // meta.visibility === 'internal' before exposing them to the model.
79 $this->meta['visibility'] = 'internal';
80 }
81
82 /**
83 * Get tool type.
84 *
85 * @return string
86 */
87 public function get_tool_type() {
88 return Tool_Types::ACTION;
89 }
90
91 /**
92 * Get input schema.
93 *
94 * @return array<string,mixed>
95 */
96 public function get_input_schema() {
97 return array(
98 'type' => 'object',
99 'properties' => array(
100 'method' => array(
101 'type' => 'string',
102 'enum' => array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ),
103 'default' => 'GET',
104 'description' => 'HTTP method.',
105 ),
106 'route' => array(
107 'type' => 'string',
108 'description' => 'REST route (e.g. "/wp/v2/posts/42"). No /wp-json prefix.',
109 ),
110 'params' => array(
111 'type' => 'object',
112 'description' => 'Query params for GET, body params for POST/PUT/PATCH/DELETE.',
113 ),
114 'headers' => array(
115 'type' => 'object',
116 'description' => 'Additional HTTP headers.',
117 ),
118 'requests' => array(
119 'type' => 'array',
120 'description' => 'Batch: array of {method, route, params}. Max 25. Ignores top-level method/route/params.',
121 'items' => array(
122 'type' => 'object',
123 'properties' => array(
124 'method' => array( 'type' => 'string' ),
125 'route' => array( 'type' => 'string' ),
126 'params' => array( 'type' => 'object' ),
127 ),
128 ),
129 ),
130 ),
131 'required' => array(),
132 );
133 }
134
135 /**
136 * Get examples.
137 *
138 * @return list<string>
139 */
140 public function get_examples() {
141 return array(
142 'run REST request to get posts',
143 'execute REST API call',
144 'call WordPress REST endpoint',
145 'delete post via REST',
146 'batch REST requests',
147 );
148 }
149
150 // check_permission() intentionally NOT overridden — uses base class
151 // which checks $this->capability ('edit_posts'). Individual REST endpoints
152 // enforce their own permission_callback via rest_do_request().
153
154 /**
155 * Dry run implementation — probes route without executing.
156 *
157 * @param array<string,mixed> $args Input arguments.
158 * @return array<string,mixed> Result array.
159 */
160 protected function dry_run( $args ) {
161 $batch = $args['requests'] ?? null;
162 if ( is_array( $batch ) && ! empty( $batch ) ) {
163 $results = array();
164 $requests = array_slice( $batch, 0, self::MAX_BATCH_SIZE );
165
166 foreach ( $requests as $i => $req ) {
167 $req = is_array( $req ) ? $req : array();
168 $method = strtoupper( sanitize_text_field( is_string( $req['method'] ?? null ) ? $req['method'] : 'GET' ) );
169 $route = sanitize_text_field( is_string( $req['route'] ?? null ) ? $req['route'] : '' );
170
171 if ( empty( $route ) ) {
172 $results[] = array(
173 'index' => $i,
174 'error' => 'Route is required.',
175 );
176 continue;
177 }
178
179 $results[] = $this->probe_request( $method, $route );
180 }
181
182 return Response::success(
183 sprintf( 'Dry run: probed %d request(s).', count( $results ) ),
184 array(
185 'dry_run' => true,
186 'results' => $results,
187 )
188 );
189 }
190
191 $method = strtoupper( sanitize_text_field( Utils::to_str( $args['method'] ?? 'GET', 'GET' ) ) );
192 $route = sanitize_text_field( Utils::to_str( $args['route'] ?? '' ) );
193
194 if ( empty( $route ) ) {
195 return Response::error( 'Route is required.', 'Provide a REST route like "/wp/v2/posts".' );
196 }
197
198 $probe = $this->probe_request( $method, $route );
199
200 return Response::success(
201 sprintf( 'Dry run: %s %s — %s.', $method, $route, Utils::to_str( $probe['permission'] ?? 'unknown', 'unknown' ) ),
202 array(
203 'dry_run' => true,
204 'probe' => $probe,
205 )
206 );
207 }
208
209 /**
210 * Execute the ability.
211 *
212 * @param array<string,mixed> $args Input arguments.
213 * @return array<string,mixed> Result array.
214 */
215 public function execute( $args ) {
216 // Batch mode.
217 $batch = $args['requests'] ?? null;
218 if ( is_array( $batch ) && ! empty( $batch ) ) {
219 /**
220 * List of batch request entries.
221 *
222 * @var array<int,mixed> $batch
223 */
224 return $this->execute_batch( $batch );
225 }
226
227 // Single request mode.
228 $method = strtoupper( sanitize_text_field( Utils::to_str( $args['method'] ?? 'GET', 'GET' ) ) );
229 $route = trim( Utils::to_str( wp_unslash( $args['route'] ?? '' ) ) );
230 /**
231 * Request parameters.
232 *
233 * @var array<string,mixed> $params
234 */
235 $params = is_array( $args['params'] ?? null ) ? $args['params'] : array();
236 /**
237 * Request headers.
238 *
239 * @var array<string,mixed> $headers
240 */
241 $headers = is_array( $args['headers'] ?? null ) ? $args['headers'] : array();
242
243 if ( empty( $route ) ) {
244 return Response::error( 'Route is required.', 'Provide a REST route like "/wp/v2/posts". Use search-endpoints to discover routes.' );
245 }
246
247 return $this->execute_single( $method, $route, $params, $headers );
248 }
249
250 /**
251 * Execute a single REST request.
252 *
253 * @param string $method HTTP method.
254 * @param string $route REST route.
255 * @param array<array-key,mixed> $params Request parameters.
256 * @param array<array-key,mixed> $headers Request headers.
257 * @return array<string,mixed> Result array.
258 */
259 private function execute_single( $method, $route, $params = array(), $headers = array() ) {
260 /**
261 * Filter whether to allow this REST request.
262 *
263 * Return WP_Error or false to block. Return true to allow.
264 *
265 * @param bool $allowed Whether the request is allowed.
266 * @param string $method HTTP method.
267 * @param string $route REST route.
268 * @param array $params Request parameters.
269 */
270 /**
271 * Filter result, bool or WP_Error per the contract above.
272 *
273 * @var mixed $allowed
274 */
275 $allowed = apply_filters( 'zip_ai_allow_rest_request', true, $method, $route, $params );
276
277 if ( is_wp_error( $allowed ) ) {
278 return Response::from_wp_error( $allowed );
279 }
280
281 if ( false === $allowed ) {
282 return Response::error(
283 sprintf( 'Request blocked: %s %s.', $method, $route ),
284 'This request was blocked by a site filter (zip_ai_allow_rest_request).'
285 );
286 }
287
288 // Ensure route starts with /.
289 if ( strpos( $route, '/' ) !== 0 ) {
290 $route = '/' . $route;
291 }
292
293 // Build WP_REST_Request.
294 $request = new \WP_REST_Request( $method, $route );
295
296 if ( ! empty( $params ) ) {
297 if ( 'GET' === $method ) {
298 $request->set_query_params( $params );
299 } else {
300 $request->set_header( 'Content-Type', 'application/json' );
301 $request->set_body( (string) wp_json_encode( $params ) );
302 }
303 }
304
305 // Set additional headers.
306 if ( ! empty( $headers ) ) {
307 foreach ( $headers as $key => $value ) {
308 $request->set_header( sanitize_text_field( (string) $key ), sanitize_text_field( Utils::to_str( $value ) ) );
309 }
310 }
311
312 // Execute via rest_do_request() — WordPress handles permission checks.
313 $response = rest_do_request( $request );
314
315 // Surface defaults that WordPress silently applied — same schema source
316 // as search-endpoints so both tools share the same knowledge.
317 /**
318 * Defaults WordPress applied to the request.
319 *
320 * @var array<string,mixed> $applied_defaults
321 */
322 $applied_defaults = ( 'GET' === $method )
323 ? RouteSchemaBuilder::get_applied_defaults( $route, $params )
324 : array();
325
326 return $this->format_response( $response, $method, $route, $applied_defaults );
327 }
328
329 /**
330 * Execute batch REST requests.
331 *
332 * @param array<array-key,mixed> $requests Array of {method, route, params}.
333 * @return array<string,mixed> Result array.
334 */
335 private function execute_batch( $requests ) {
336 if ( count( $requests ) > self::MAX_BATCH_SIZE ) {
337 return Response::error(
338 sprintf( 'Batch too large: %d requests (max %d).', count( $requests ), self::MAX_BATCH_SIZE ),
339 'Split into smaller batches of ' . self::MAX_BATCH_SIZE . ' or fewer.'
340 );
341 }
342
343 $results = array();
344 $succeeded = 0;
345 $failed = 0;
346
347 foreach ( $requests as $i => $req ) {
348 $req = is_array( $req ) ? $req : array();
349 $method = strtoupper( sanitize_text_field( Utils::to_str( $req['method'] ?? 'GET', 'GET' ) ) );
350 $route = sanitize_text_field( Utils::to_str( $req['route'] ?? '' ) );
351 /**
352 * Per-request parameters.
353 *
354 * @var array<string,mixed> $params
355 */
356 $params = is_array( $req['params'] ?? null ) ? $req['params'] : array();
357
358 if ( empty( $route ) ) {
359 $results[] = array(
360 'index' => $i,
361 'method' => $method,
362 'route' => '',
363 'success' => false,
364 'error' => 'Route is required.',
365 );
366 ++$failed;
367 continue;
368 }
369
370 $result = $this->execute_single( $method, $route, $params );
371
372 $results[] = array(
373 'index' => $i,
374 'method' => $method,
375 'route' => $route,
376 ) + $result;
377
378 if ( ! empty( $result['success'] ) ) {
379 ++$succeeded;
380 } else {
381 ++$failed;
382 }
383 }
384
385 $total = count( $requests );
386 $message = sprintf( 'Batch complete: %d/%d succeeded, %d failed.', $succeeded, $total, $failed );
387 $data = array(
388 'results' => $results,
389 'total' => $total,
390 'succeeded' => $succeeded,
391 'failed' => $failed,
392 );
393
394 // A batch with ANY failed sub-request is NOT a success. Reporting the
395 // whole call as success let the model tell the user "done" when some
396 // (or all) operations actually failed — a false confirmation. Surface
397 // it as a failure carrying the per-item results so the model can see
398 // exactly which requests to retry.
399 //
400 // Self-documenting retry guidance: a partial batch's succeeded items
401 // ALREADY ran server-side. Re-running the whole batch on retry would
402 // duplicate the non-idempotent ones (extra posts/pages/etc.). Tell the
403 // caller to retry ONLY the failed items from `data.results`. (Pure
404 // mitigation — the model still owns the retry; this just makes the
405 // safe path explicit, the way other errors here carry a `suggestion`.)
406 if ( $failed > 0 ) {
407 $suggestion = $succeeded > 0
408 ? 'Partial success — the succeeded sub-requests already ran. Retry ONLY the items whose result shows "success": false in data.results; do NOT re-send the whole batch or the succeeded writes will run again and create duplicates.'
409 : 'Retry the failed sub-requests after fixing each error in data.results.';
410 return Response::error( $message, $suggestion, $data );
411 }
412
413 return Response::success( $message, $data );
414 }
415
416 /**
417 * Probe a REST request without executing.
418 *
419 * Uses route pattern matching against registered routes and probes
420 * the permission_callback directly.
421 *
422 * @param string $method HTTP method.
423 * @param string $route REST route.
424 * @return array<string,mixed> Probe result.
425 */
426 private function probe_request( $method, $route ) {
427 // Ensure route starts with /.
428 if ( strpos( $route, '/' ) !== 0 ) {
429 $route = '/' . $route;
430 }
431
432 $server = rest_get_server();
433 $routes = $server->get_routes();
434
435 // Find matching route handler by testing regex patterns.
436 $matched_handler = null;
437 foreach ( $routes as $route_pattern => $handlers ) {
438 $regex = '#^' . $route_pattern . '$#';
439 if ( ! is_array( $handlers ) || ! preg_match( $regex, $route ) ) {
440 continue;
441 }
442
443 // Find handler that supports the requested method.
444 foreach ( $handlers as $handler ) {
445 if ( ! is_array( $handler ) || ! isset( $handler['methods'] ) ) {
446 continue;
447 }
448 $handler_methods = is_array( $handler['methods'] ) ? $handler['methods'] : array( Utils::to_str( $handler['methods'] ) => true );
449 if ( isset( $handler_methods[ $method ] ) ) {
450 $matched_handler = $handler;
451 break 2;
452 }
453 }
454 }
455
456 if ( ! $matched_handler ) {
457 return array(
458 'method' => $method,
459 'route' => $route,
460 'exists' => false,
461 'permission' => 'unknown',
462 'error' => 'No matching route found.',
463 );
464 }
465
466 // Probe permission.
467 $permission = 'unknown';
468 if ( isset( $matched_handler['permission_callback'] ) && is_callable( $matched_handler['permission_callback'] ) ) {
469 try {
470 $request = new \WP_REST_Request( $method, $route );
471 $perm_result = call_user_func( $matched_handler['permission_callback'], $request );
472 if ( is_wp_error( $perm_result ) ) {
473 $permission = 'denied';
474 } else {
475 $permission = $perm_result ? 'allowed' : 'denied';
476 }
477 } catch ( \Exception $e ) {
478 $permission = 'unknown';
479 } catch ( \Error $e ) {
480 $permission = 'unknown';
481 }
482 }
483
484 return array(
485 'method' => $method,
486 'route' => $route,
487 'exists' => true,
488 'permission' => $permission,
489 );
490 }
491
492 /**
493 * Format a WP_REST_Response for output.
494 *
495 * Extracts pagination headers and truncates large arrays.
496 *
497 * @param \WP_REST_Response $response REST response.
498 * @param string $method HTTP method.
499 * @param string $route REST route.
500 * @param array<array-key,mixed> $applied_defaults Defaults WordPress silently applied.
501 * @return array<string,mixed> Formatted result.
502 */
503 private function format_response( $response, $method, $route, $applied_defaults = array() ) {
504 $status = $response->get_status();
505 $data = $response->get_data();
506
507 // Extract pagination headers.
508 $headers = $response->get_headers();
509 $pagination = array();
510
511 if ( isset( $headers['X-WP-Total'] ) ) {
512 $pagination['total'] = (int) ( is_scalar( $headers['X-WP-Total'] ) ? $headers['X-WP-Total'] : 0 );
513 }
514 if ( isset( $headers['X-WP-TotalPages'] ) ) {
515 $pagination['total_pages'] = (int) ( is_scalar( $headers['X-WP-TotalPages'] ) ? $headers['X-WP-TotalPages'] : 0 );
516 }
517
518 // Handle error responses.
519 if ( $status >= 400 ) {
520 $error_message = 'Request failed.';
521 $suggestion = '';
522
523 if ( is_array( $data ) ) {
524 if ( isset( $data['message'] ) ) {
525 $error_message = Utils::to_str( $data['message'] );
526 }
527 if ( isset( $data['code'] ) ) {
528 if ( 'rest_forbidden' === $data['code'] ) {
529 $suggestion = 'Permission denied. The current user lacks the required capability for this endpoint.';
530 } elseif ( 'rest_no_route' === $data['code'] ) {
531 $suggestion = 'This route does not exist. Use search-endpoints to find the actual registered routes for this plugin — do NOT guess endpoint names.';
532 }
533 }
534 }
535
536 $result = array(
537 'success' => false,
538 'error' => sprintf( '%s %s → %d: %s', $method, $route, $status, $error_message ),
539 'status_code' => $status,
540 );
541
542 if ( ! empty( $suggestion ) ) {
543 $result['suggestion'] = $suggestion;
544 }
545
546 return $result;
547 }
548
549 // Suspicious 2xx with empty/null data on a write request — treat as failure.
550 // Real CREATE/UPDATE/DELETE endpoints return the affected entity or { id, ... }.
551 // An empty 200 on POST/PUT/PATCH/DELETE almost always means the handler bailed silently.
552 $is_write = in_array( strtoupper( $method ), array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true );
553 $is_empty = ( null === $data || '' === $data || ( is_array( $data ) && empty( $data ) ) );
554 if ( $is_write && $is_empty ) {
555 return array(
556 'success' => false,
557 'error' => sprintf(
558 '%s %s → %d but response body is empty. The endpoint accepted the request but returned nothing — the handler likely failed silently or the route does not actually create what you expected.',
559 $method,
560 $route,
561 $status
562 ),
563 'status_code' => $status,
564 'suggestion' => 'Verify by GET-ing the entity you tried to create. If it does not exist, the route is wrong. Use search-endpoints + read plugin source code to find the correct create endpoint.',
565 );
566 }
567
568 // Truncate large array responses.
569 $truncated = false;
570 if ( is_array( $data ) && ! $this->is_assoc( $data ) && count( $data ) > self::MAX_RESPONSE_ITEMS ) {
571 $total_items = count( $data );
572 $data = array_slice( $data, 0, self::MAX_RESPONSE_ITEMS );
573 $truncated = true;
574 }
575
576 $result_data = array(
577 'status_code' => $status,
578 'data' => $data,
579 );
580
581 if ( ! empty( $pagination ) ) {
582 $result_data['pagination'] = $pagination;
583 }
584
585 if ( $truncated ) {
586 $result_data['truncated'] = true;
587 $result_data['truncated_message'] = sprintf(
588 'Response truncated to %d items (total: %d). Use pagination params (per_page, page) to get more.',
589 self::MAX_RESPONSE_ITEMS,
590 $total_items
591 );
592 }
593
594 if ( ! empty( $applied_defaults ) ) {
595 $result_data['applied_defaults'] = $applied_defaults;
596 }
597
598 $message = sprintf( '%s %s → %d OK.', $method, $route, $status );
599 if ( ! empty( $applied_defaults ) ) {
600 $parts = array();
601 foreach ( $applied_defaults as $key => $val ) {
602 $parts[] = $key . '=' . ( is_string( $val ) ? $val : wp_json_encode( $val ) );
603 }
604 $message .= ' Note: defaults applied: ' . implode( ', ', $parts ) . '.';
605 }
606
607 return Response::success( $message, $result_data );
608 }
609
610 /**
611 * Check if an array is associative.
612 *
613 * @param array<array-key,mixed> $arr Array to check.
614 * @return bool True if associative.
615 */
616 private function is_assoc( $arr ) {
617 if ( empty( $arr ) ) {
618 return false;
619 }
620 return array_keys( $arr ) !== range( 0, count( $arr ) - 1 );
621 }
622 }
623