PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.12
ZIP AI – AI Website Builder & AI Agent v0.0.12
0.0.14 0.0.13 0.0.12 0.0.11 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 0.0.12, at inc/api/rest-api.php

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