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 / api / rest-api.php

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

654 lines 21.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API - Handle MCP tool execution via REST API
4 *
5 * @package zip-ai
6 */
7
8 namespace ZipAI\MCP\Classes\Api;
9
10 // Exit if accessed directly.
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 use ZipAI\MCP\Classes\Core\Helper;
16 use ZipAI\MCP\Classes\Core\Utils;
17 use ZipAI\MCP\Classes\Security\Protected_Options_Filter;
18
19 // Explicit require — composer's classmap autoloader may not pick up
20 // newly-added files until `composer dump-autoload` runs in the install.
21 // Loading the filter file directly guarantees the class is available
22 // regardless of classmap freshness.
23 if ( ! class_exists( '\\ZipAI\\MCP\\Classes\\Security\\Protected_Options_Filter' ) ) {
24 require_once dirname( __DIR__ ) . '/security/protected-options-filter.php';
25 }
26
27 /**
28 * The REST_API Class.
29 * Handles REST API endpoints for MCP tool execution.
30 */
31 class REST_API {
32
33 /**
34 * Capability floor for every route in this class — the MCP tool surface and
35 * the site-scan trigger. `Abstract_Ability::$capability` defaults to the same
36 * value so a per-ability omission can never grant more than the ingress.
37 *
38 * @var string
39 */
40 public const INGRESS_CAPABILITY = 'manage_options';
41
42 /**
43 * Constructor of this class.
44 *
45 * @since 1.0.0
46 * @return void
47 */
48 public function __construct() {
49 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
50
51 // Install the protected-options filters at WP's pre_update_option_<key>
52 // layer. This is the catch-all backstop for any code path — CLI, REST,
53 // AJAX, custom-plugin endpoints, code snippets — that calls
54 // update_option() while inside an MCP-bound request. The filters are
55 // registered once; the actual refusal is gated on the per-request
56 // `enter_mcp()` flag toggled below in `handle_mcp_request`.
57 Protected_Options_Filter::install();
58 }
59
60 /**
61 * Register REST API routes.
62 *
63 * @since 1.0.0
64 * @return void
65 */
66 public function register_routes() {
67 // Strict JSON-RPC 2.0 MCP Endpoint
68 register_rest_route(
69 'zip-ai/v1',
70 '/mcp',
71 array(
72 'methods' => 'POST',
73 'callback' => array( $this, 'handle_mcp_request' ),
74 'permission_callback' => array( $this, 'check_permission' ),
75 )
76 );
77
78 // Trigger site scan — sends raw site data to the server for memory enrichment.
79 register_rest_route(
80 'zip-ai/v1',
81 '/site-scan',
82 array(
83 'methods' => 'POST',
84 'callback' => array( $this, 'handle_site_scan' ),
85 'permission_callback' => array( $this, 'check_permission' ),
86 )
87 );
88 }
89
90 /**
91 * Single ingress point for all JSON-RPC 2.0 MCP requests.
92 *
93 * @param \WP_REST_Request $request The REST request object.
94 * @return \WP_REST_Response
95 */
96 public function handle_mcp_request( $request ) {
97 // Handle Authentication Context (from HTTP Headers/Session)
98 $this->setup_user_context( $request );
99
100 $body = $request->get_json_params();
101
102 $raw_method = $body['method'] ?? null;
103 $method = is_string( $raw_method ) ? $raw_method : '';
104
105 $raw_id = $body['id'] ?? null;
106 $id = ( is_int( $raw_id ) || is_string( $raw_id ) ) ? $raw_id : null;
107
108 // JSON-RPC params object from the request body.
109 /**
110 * Narrowed type for `$params`.
111 *
112 * @var array<string,mixed> $params
113 */
114 $params = isset( $body['params'] ) && is_array( $body['params'] ) ? $body['params'] : array();
115
116 if ( empty( $method ) ) {
117 return $this->format_mcp_error( $id, -32600, 'Invalid Request: Missing method' );
118 }
119
120 // Mark this request as MCP-bound so the protected-options filters
121 // installed via Protected_Options_Filter::install() refuse mutations
122 // to site-critical keys (siteurl, home, template, …) regardless of
123 // which ability-specific code path tries to write them. Cleared in
124 // the `finally` block — `register_shutdown_function` is the safety
125 // net for fatal-error paths.
126 Protected_Options_Filter::enter_mcp();
127
128 try {
129 switch ( $method ) {
130 case 'initialize':
131 return $this->handle_initialize( $id );
132 case 'tools/list':
133 return $this->handle_tools_list( $id );
134 case 'tools/call':
135 return $this->handle_tools_call( $id, $params );
136 case 'notifications/initialized':
137 // Fire-and-forget notification, no response needed.
138 return new \WP_REST_Response( null, 200 );
139 default:
140 return $this->format_mcp_error( $id, -32601, "Method not found: {$method}" );
141 }
142 } catch ( \Throwable $e ) {
143 // Never send the exception text to the client — it can leak class
144 // names, file paths, and DB errors. Keep the detail server-side
145 // (WP_DEBUG) and return a static message; JSON-RPC callers branch on
146 // the numeric code (-32000), not the prose.
147 Utils::debug_log( sprintf( 'MCP dispatch failed for "%s"', $method ), $e->getMessage() );
148 return $this->format_mcp_error( $id, -32000, 'Internal Server Error' );
149 } finally {
150 Protected_Options_Filter::exit_mcp();
151 }
152 }
153
154 /**
155 * Handle MCP Initialization Protocol.
156 *
157 * @param string|int|null $id JSON-RPC request id.
158 * @return \WP_REST_Response
159 */
160 private function handle_initialize( $id ) {
161 return $this->format_mcp_response(
162 $id,
163 array(
164 'protocolVersion' => '2024-11-05',
165 'capabilities' => array(
166 'tools' => array(),
167 ),
168 'serverInfo' => array(
169 'name' => 'ZipWP WordPress MCP',
170 'version' => '1.0.0',
171 ),
172 )
173 );
174 }
175
176 /**
177 * Handle MCP Tools List Protocol.
178 *
179 * @param string|int|null $id JSON-RPC request id.
180 * @return \WP_REST_Response
181 */
182 private function handle_tools_list( $id ) {
183 if ( ! class_exists( 'WP_Abilities_Registry' ) ) {
184 return $this->format_mcp_error( $id, -32001, 'Abilities API is not available' );
185 }
186
187 $registry = \WP_Abilities_Registry::get_instance();
188 if ( ! $registry instanceof \WP_Abilities_Registry ) {
189 return $this->format_mcp_error( $id, -32001, 'Abilities API is not available' );
190 }
191 $abilities = $registry->get_all_registered();
192 $tools = array();
193
194 // Exclude mcp-adapter/get-ability-info from the catalog. The client
195 // already receives every tool's `inputSchema` in this same payload, so
196 // runtime schema introspection is redundant — and this tool only adds
197 // failure surface: it resolves names by canonical `namespace/name`, but
198 // the client knows tools as `namespace__name`, so the argument never
199 // resolves and the call returns a misleading "invalid permissions",
200 // dead-ending arg-correction recovery. The client's own recovery policy
201 // already steers AWAY from it (recoveryHints VALIDATION rule: "fix the
202 // arguments, do NOT discover alternates"). discover-abilities and
203 // execute-ability are KEPT — the client relies on them as the surface-
204 // switch escape hatch for wp-cli-not-exposed errors.
205 $excluded_meta_abilities = array(
206 'mcp-adapter/get-ability-info',
207 );
208
209 foreach ( $abilities as $ability_name => $ability ) {
210 $ability_display_name = $ability->get_name();
211 $resolved_name = $ability_display_name ? $ability_display_name : $ability_name;
212 if ( in_array( $resolved_name, $excluded_meta_abilities, true ) ) {
213 continue;
214 }
215
216 $input_schema = $ability->get_input_schema();
217
218 $tool = array(
219 'name' => $resolved_name,
220 'description' => $ability->get_description(),
221 'inputSchema' => $input_schema ? $input_schema : array(
222 'type' => 'object',
223 'properties' => new \stdClass(),
224 ),
225 );
226
227 // Expose output schema when declared — lets the model learn the tool's
228 // response contract from the schema rather than prose.
229 $output_schema = $ability->get_output_schema();
230 if ( ! empty( $output_schema ) ) {
231 $tool['outputSchema'] = $output_schema;
232 }
233
234 $label = $ability->get_label();
235 if ( ! empty( $label ) ) {
236 $tool['title'] = $label;
237 }
238
239 // Expose tool_type (read|write|list|search|action|delete) so the client's
240 // classifier can set mutates_state from the source-of-truth annotation
241 // instead of guessing from the tool name. Without this, the client falls
242 // back to a verb-based heuristic that misclassifies tools like
243 // read-type tools as writes, blocking legitimate reads
244 // during plan/discover stages. WP_Ability core wrappers expose
245 // `get_meta()`; Abstract_Ability instances also expose `get_tool_type()`.
246 $tool_type = null;
247 $meta = $ability->get_meta();
248 if ( ! empty( $meta['tool_type'] ) ) {
249 $tool_type = $meta['tool_type'];
250 }
251 if ( null === $tool_type && method_exists( $ability, 'get_tool_type' ) ) {
252 $tool_type = $ability->get_tool_type();
253 }
254 if ( ! empty( $tool_type ) ) {
255 $tool['tool_type'] = $tool_type;
256 }
257
258 // MCP-spec safety annotations. Prefer the ability's own
259 // declaration (theme abilities publish one via meta); otherwise
260 // derive from tool_type. Without this key on the wire, a client
261 // that gates confirmation on destructiveHint never gates ANY tool
262 // here — the missing confirmation hop behind the
263 // update-navigation styling-loss incident.
264 $declared_annotations = isset( $meta['annotations'] ) && is_array( $meta['annotations'] ) ? $meta['annotations'] : null;
265 if ( null !== $declared_annotations ) {
266 // destructiveHint is only meaningful when readOnlyHint is
267 // false (MCP spec), so its default follows the readonly
268 // declaration — a bare `readonly: true` must not emit the
269 // contradictory readOnlyHint=true + destructiveHint=true.
270 $declared_readonly = (bool) ( $declared_annotations['readonly'] ?? false );
271 $tool['annotations'] = array(
272 'readOnlyHint' => $declared_readonly,
273 'destructiveHint' => (bool) ( $declared_annotations['destructive'] ?? ! $declared_readonly ),
274 'idempotentHint' => (bool) ( $declared_annotations['idempotent'] ?? $declared_readonly ),
275 );
276 } elseif ( ! empty( $tool_type ) ) {
277 $is_read_shaped = in_array( $tool_type, array( 'read', 'list', 'search' ), true );
278 $tool['annotations'] = array(
279 'readOnlyHint' => $is_read_shaped,
280 'destructiveHint' => ! $is_read_shaped,
281 'idempotentHint' => $is_read_shaped,
282 );
283 }
284
285 // Read-only sub-action allowlist for multiplexed abilities (those
286 // that route many operations through a single `action` enum).
287 // Forwarded to the client so its writes-require-approval gate can
288 // classify `action:"list"` on a generally-destructive tool as a
289 // safe read. Empty when the ability doesn't declare any. The
290 // registry returns a `WP_Ability` wrapper (not the original
291 // subclass), so we read the allowlist from the meta map populated
292 // by Abstract_Ability::register().
293 $read_only_actions = null;
294 $ability_meta_for_read = $ability->get_meta();
295 if ( ! empty( $ability_meta_for_read['read_only_actions'] ) && is_array( $ability_meta_for_read['read_only_actions'] ) ) {
296 $read_only_actions = $ability_meta_for_read['read_only_actions'];
297 }
298 if ( null === $read_only_actions && method_exists( $ability, 'get_read_only_actions' ) ) {
299 $read_only_actions = $ability->get_read_only_actions();
300 }
301 if ( is_array( $read_only_actions ) && ! empty( $read_only_actions ) ) {
302 $tool['read_only_actions'] = array_values( $read_only_actions );
303 }
304
305 // Forward a whitelisted subset of ability meta. Only keys the client
306 // actually consumes are exposed — keeps tools/list payload bounded and
307 // prevents accidental leakage of new internal fields if abilities later
308 // add private metadata. Update this list when a new key is needed
309 // (and document the reason in the consuming code).
310 $ability_meta = $ability->get_meta();
311 if ( ! empty( $ability_meta ) ) {
312 $default_allowed_meta_keys = array(
313 'tool_type',
314 'visibility',
315 'execution_mode',
316 'js_handler',
317 'resource',
318 'examples',
319 'api_endpoint',
320 'boost_screens',
321 'required_plugin',
322 'required_plugin_version',
323 'version',
324 // Server-side preflight against the site's installed-plugin list.
325 // Declared on plugin lifecycle abilities (Activate/Deactivate/Delete)
326 // so the server can refuse model-authored slugs that do not match
327 // a currently installed plugin BEFORE the call reaches the browser.
328 // Without this key in the allowlist, array_intersect_key strips
329 // the meta and the server never receives it.
330 'preflight_resource',
331 );
332 $allowed_meta_keys = apply_filters(
333 'zip_ai_tools_list_allowed_meta_keys',
334 $default_allowed_meta_keys,
335 $ability_name,
336 $ability
337 );
338 if ( ! is_array( $allowed_meta_keys ) || empty( $allowed_meta_keys ) ) {
339 $allowed_meta_keys = $default_allowed_meta_keys;
340 }
341 // Meta keys permitted to be forwarded for this ability.
342 /**
343 * Narrowed type for `$allowed_meta_keys`.
344 *
345 * @var array<int|string,string> $allowed_meta_keys
346 */
347 $forwarded_meta = array_intersect_key( $ability_meta, array_flip( $allowed_meta_keys ) );
348 if ( ! empty( $forwarded_meta ) ) {
349 $tool['meta'] = $forwarded_meta;
350 }
351 }
352
353 $tools[] = $tool;
354 }
355
356 return $this->format_mcp_response( $id, array( 'tools' => $tools ) );
357 }
358
359 /**
360 * Handle MCP Tools Call Protocol.
361 *
362 * @param string|int|null $id JSON-RPC request id.
363 * @param array<string,mixed> $params JSON-RPC params, expects `name` and `arguments`.
364 * @return \WP_REST_Response
365 */
366 private function handle_tools_call( $id, $params ) {
367 $raw_tool_name = $params['name'] ?? '';
368 $tool_name = is_string( $raw_tool_name ) ? $raw_tool_name : '';
369 $arguments = $params['arguments'] ?? array();
370
371 if ( empty( $tool_name ) ) {
372 return $this->format_mcp_error( $id, -32602, 'Invalid params: tool name required' );
373 }
374
375 if ( ! class_exists( 'WP_Abilities_Registry' ) ) {
376 return $this->format_mcp_error( $id, -32001, 'Abilities API is not available' );
377 }
378
379 $registry = \WP_Abilities_Registry::get_instance();
380 if ( ! $registry instanceof \WP_Abilities_Registry ) {
381 return $this->format_mcp_error( $id, -32001, 'Abilities API is not available' );
382 }
383 $ability = $registry->get_registered( $tool_name );
384
385 if ( ! $ability ) {
386 return $this->format_mcp_error( $id, -32601, "Tool not found: {$tool_name}" );
387 }
388
389 // Execute the tool — WP_Ability::execute() dispatches to the registered execute_callback
390 // (Abstract_Ability::handle_execute), which includes validation, rate-limiting, try-catch.
391 $result = $ability->execute( $arguments );
392
393 // Check if it's already a standard response from our Response class (Response::success/error)
394 if ( is_array( $result ) && isset( $result['success'] ) ) {
395 if ( ! $result['success'] ) {
396 return $this->format_mcp_response(
397 $id,
398 array(
399 'content' => array(
400 array(
401 'type' => 'text',
402 'text' => wp_json_encode( $result ),
403 ),
404 ),
405 'isError' => true,
406 )
407 );
408 }
409
410 return $this->format_mcp_response(
411 $id,
412 array(
413 'content' => array(
414 array(
415 'type' => 'text',
416 'text' => wp_json_encode( $result ),
417 ),
418 ),
419 )
420 );
421 }
422
423 if ( is_wp_error( $result ) ) {
424 return $this->format_mcp_response(
425 $id,
426 array(
427 'content' => array(
428 array(
429 'type' => 'text',
430 'text' => wp_json_encode(
431 array(
432 'success' => false,
433 'error' => $result->get_error_message(),
434 'code' => $result->get_error_code(),
435 )
436 ),
437 ),
438 ),
439 'isError' => true,
440 )
441 );
442 }
443
444 return $this->format_mcp_response(
445 $id,
446 array(
447 'content' => array(
448 array(
449 'type' => 'text',
450 'text' => wp_json_encode(
451 array(
452 'success' => true,
453 'data' => $result,
454 )
455 ),
456 ),
457 ),
458 )
459 );
460 }
461
462 /**
463 * Format an MCP JSON-RPC standard response.
464 *
465 * @param string|int|null $id JSON-RPC request id.
466 * @param array<string,mixed> $result JSON-RPC result payload.
467 * @return \WP_REST_Response
468 */
469 private function format_mcp_response( $id, $result ) {
470 return new \WP_REST_Response(
471 array(
472 'jsonrpc' => '2.0',
473 'id' => $id,
474 'result' => $result,
475 ),
476 200
477 );
478 }
479
480 /**
481 * Format an MCP JSON-RPC standard error.
482 *
483 * @param string|int|null $id JSON-RPC request id.
484 * @param int $code JSON-RPC error code.
485 * @param string $message Human-readable error message.
486 * @return \WP_REST_Response
487 */
488 private function format_mcp_error( $id, $code, $message ) {
489 return new \WP_REST_Response(
490 array(
491 'jsonrpc' => '2.0',
492 'id' => $id,
493 'error' => array(
494 'code' => $code,
495 'message' => $message,
496 ),
497 ),
498 200
499 );
500 }
501
502 /**
503 * Extracted user-context setup. App Password Basic auth via
504 * {@see is_basic_authenticated()} resolves and sets the current user
505 * inside `wp_authenticate_application_password()` as a side effect,
506 * so this method is a thin wrapper that just triggers the check —
507 * subsequent capability lookups (in this handler and in downstream
508 * third-party hooks like Elementor) see the App Password owner.
509 *
510 * The legacy `auth_token_wp_user_id` binding + `x_wp_user_id` header
511 * gate + `legacy_token_healed` migration scaffolding are retired:
512 * identity is now bound to the credential itself, not asserted by
513 * the caller.
514 *
515 * @param \WP_REST_Request $request The REST request object.
516 * @return void
517 */
518 private function setup_user_context( $request ) {
519 $this->is_basic_authenticated();
520 }
521
522 /**
523 * Handle site scan — collects raw site data and sends to the server.
524 *
525 * @param \WP_REST_Request $request The REST request object.
526 * @return \WP_REST_Response
527 */
528 public function handle_site_scan( $request ) {
529 \ZipAI\MCP\Classes\Core\Site_Scanner::run_scan();
530
531 return new \WP_REST_Response(
532 array(
533 'success' => true,
534 'message' => 'Site scan sent.',
535 ),
536 200
537 );
538 }
539
540 /**
541 * Check if the current request has permission to execute tools.
542 *
543 * This is ONE gate with ONE floor. Basic auth only resolves who the caller
544 * is. The capability check then applies to the current identity. That
545 * identity is the App Password owner or the logged-in browser session.
546 *
547 * Authentication is deliberately not authorization. Core puts no capability
548 * floor on Application Passwords. Every user can mint one. So admitting any
549 * valid one left the per-ability `$capability` as the only defence. This
550 * surface includes run-wp-cli and the snippet engine. The floor matches
551 * every UI that fronts this API. It also matches the credential the server
552 * uses. `Helper::ensure_app_password_provisioned()` only mints under a
553 * `manage_options` user.
554 *
555 * Recovery note. The bound App Password user can later lose
556 * `manage_options`. Then every call returns 401. Reconnecting AS THAT USER
557 * cannot fix it. `ensure_app_password_provisioned()` refuses to mint for
558 * them. Reconnecting as a different administrator does work. The idempotency
559 * lookup is scoped per user. So the stale UUID falls through and a fresh
560 * credential is minted.
561 *
562 * @param \WP_REST_Request $request The REST request object.
563 * @return bool|\WP_Error True if permission granted, WP_Error otherwise.
564 */
565 public function check_permission( $request ) {
566 // The return value is intentionally unused. This resolves the App
567 // Password owner and makes it the current user. The check below then
568 // runs against the credential's real identity.
569 $this->is_basic_authenticated();
570
571 if ( current_user_can( self::INGRESS_CAPABILITY ) ) {
572 return true;
573 }
574
575 // 401 when nobody is identified, 403 once someone is. Before the floor
576 // existed only anonymous callers reached this line, so a flat 401 was
577 // accurate; now a perfectly valid App Password whose owner lacks the
578 // capability lands here too, and telling that caller "unauthenticated"
579 // invites a pointless credential re-mint.
580 return new \WP_Error(
581 'rest_forbidden',
582 __( 'You do not have permission to execute tools.', 'zip-ai' ),
583 array( 'status' => rest_authorization_required_code() )
584 );
585 }
586
587 /**
588 * Check if the request is authenticated via Application Password
589 * Basic auth.
590 *
591 * Reads `Authorization: Basic <base64(username:app_password)>`,
592 * decodes the credential, and delegates to WP core's
593 * {@see wp_authenticate_application_password}. On success the
594 * current user is set as a side effect so capability checks
595 * downstream resolve against the App Password's owner.
596 *
597 * @return bool True if authenticated, false otherwise.
598 */
599 protected function is_basic_authenticated() {
600 $credential = $this->get_basic_credential();
601 if ( null === $credential ) {
602 return false;
603 }
604
605 list( $username, $password ) = $credential;
606 if ( '' === $username || '' === $password ) {
607 return false;
608 }
609
610 // `wp_authenticate_application_password` returns a WP_User on
611 // success, or a WP_Error / null on failure.
612 $result = wp_authenticate_application_password( null, $username, $password );
613 if ( $result instanceof \WP_User ) {
614 wp_set_current_user( $result->ID );
615 return true;
616 }
617
618 return false;
619 }
620
621 /**
622 * Pull `(username, password)` out of an `Authorization: Basic …`
623 * header. Returns null when the header is absent, malformed, or
624 * uses any scheme other than Basic.
625 *
626 * @return array{0: string, 1: string}|null
627 */
628 private function get_basic_credential() {
629 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Authorization header decoded for credential comparison only.
630 $auth_header = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? $_SERVER['HTTP_AUTHORIZATION'] : '';
631
632 if ( empty( $auth_header ) && function_exists( 'getallheaders' ) ) {
633 $headers = getallheaders();
634 $auth_header = $headers['Authorization'] ?? $headers['authorization'] ?? '';
635 }
636
637 if ( ! is_string( $auth_header ) || 0 !== stripos( $auth_header, 'Basic ' ) ) {
638 return null;
639 }
640
641 $encoded = trim( substr( $auth_header, 6 ) );
642 if ( '' === $encoded ) {
643 return null;
644 }
645 $decoded = base64_decode( $encoded, true );
646 if ( false === $decoded || strpos( $decoded, ':' ) === false ) {
647 return null;
648 }
649
650 list( $username, $password ) = explode( ':', $decoded, 2 );
651 return array( (string) $username, (string) $password );
652 }
653 }
654