PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.6
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.6
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / classes / api / rest-api.php

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

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