PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.14
ZIP AI – AI Website Builder & AI Agent v0.0.14
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 / external-mcp.php

external-mcp.php in ZIP AI – AI Website Builder & AI Agent 0.0.14, at inc/api/external-mcp.php

480 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 * External MCP server. Third-party AI clients connect to this endpoint. These
4 * clients include Claude Code, Claude Desktop, Codex, Gemini CLI and Cursor.
5 *
6 * The server runs at `/wp-json/mcp/zip-ai`. It REBRANDS the MCP Adapter's own
7 * default server (`mcp_adapter_default_server_config`). It does not stand a
8 * second server beside it. This gives one server and one URL. The discover,
9 * get-info and execute dispatcher comes with it. The auto-discovered resources
10 * and prompts also come with it. If another plugin already claimed that server,
11 * this plugin registers its own server as a fallback (`register_server`).
12 *
13 * This server is distinct from the plugin's own JSON-RPC endpoint at
14 * `/wp-json/zip-ai/v1/mcp`. That endpoint stays untouched. It sees every
15 * registered ability. There are two transports and one ability registry.
16 *
17 * The server has three deliberate properties:
18 * 1. It is ON by default. The Connection screen can switch it off. When you
19 * switch it off, the route is removed. The abilities also lose the
20 * `mcp.public` flag. Nothing of this plugin stays reachable through the
21 * adapter's own server.
22 * 2. This route raises the transport capability to `publish_pages`
23 * (`route_capability`). The default-server factory passes no permission
24 * callback. Without this, the adapter accepts bare `read`. Any subscriber
25 * with an Application Password could then reach it.
26 * 3. The dispatcher is REUSED, not reimplemented. The adapter's own
27 * `meta.mcp.public` flag gates it. `External_Tool_Policy` sets this flag
28 * for every Era ability. There is no denylist. The caller authenticated as
29 * a real WP user. Each ability enforces its own capability. Annotations
30 * tell the client what to confirm.
31 *
32 * @since 0.0.8
33 * @package zip-ai
34 */
35
36 namespace ZipAI\MCP\Classes\Api;
37
38 use ZipAI\MCP\Classes\Core\External_Tool_Policy;
39 use ZipAI\MCP\Classes\Core\Utils;
40 use ZipAI\MCP\Classes\Security\Protected_Options_Filter;
41
42 defined( 'ABSPATH' ) || exit;
43
44 /**
45 * Registers the curated, opt-in MCP server for external AI clients.
46 */
47 class External_Mcp {
48
49 const OPTION_KEY = 'zipai_external_mcp_enabled';
50 const DEFAULT_SERVER_ID = 'mcp-adapter-default-server';
51 const ENABLED = '1';
52 const DISABLED = '0';
53 /**
54 * Capability required to speak to this endpoint AT ALL.
55 *
56 * `manage_options`, because the import writes run under the site's BOUND
57 * ADMIN credential regardless of who called — admitting a lower role would
58 * let them cause admin-level writes. Note the bar cannot rest on how
59 * credentials are issued: the Connection screen is admin-only, but WordPress
60 * lets ANY user mint an application password from their own profile, so the
61 * transport check here is what actually keeps non-admins out.
62 *
63 * The adapter's own default here is `read` (any logged-in user, subscriber
64 * included), which would let a subscriber enumerate every public ability's
65 * name, description and input schema through `discover-abilities`.
66 */
67 const CAPABILITY = 'manage_options';
68
69 const SERVER_ID = 'zip-ai';
70 const ROUTE = 'zip-ai';
71 const NAMESPACE = 'mcp';
72
73 /**
74 * Hook the adapter's init.
75 *
76 * @return void
77 */
78 public function __construct() {
79 // Preferred path: rebrand the adapter's OWN default server as Era's rather
80 // than standing a second one beside it. One server, one URL, and the
81 // dispatcher trio plus auto-discovered resources/prompts come with it.
82 add_filter( 'mcp_adapter_default_server_config', array( $this, 'brand_default_server' ) );
83 // The default-server factory passes no transport permission callback, so
84 // the transport would fall back to the `read` capability — any subscriber
85 // with an Application Password. Raise it, scoped to our route only.
86 add_filter( 'mcp_adapter_default_transport_permission_user_capability', array( $this, 'route_capability' ), 10, 2 );
87 // Fallback only: runs after the factory (priority 20) and stands up our own
88 // server if another plugin had already claimed the default one.
89 add_action( 'mcp_adapter_init', array( $this, 'register_server' ), 20 );
90 // Only present in MCP Adapter 0.5.0+. On 0.4.1 there is no hook, so the
91 // masking below cannot be corrected — which is precisely why the two
92 // error-carrying import abilities are ADVERTISED as their own tools
93 // rather than reached through the dispatcher.
94 add_filter( 'mcp_adapter_tool_call_result', array( $this, 'unmask_failed_dispatch' ), 10, 3 );
95 // The protected-options backstop holds on this transport too: entered last
96 // before execute, left first after it.
97 add_filter( 'mcp_adapter_pre_tool_call', array( $this, 'enter_protected_context' ), PHP_INT_MAX, 4 );
98 add_filter( 'mcp_adapter_tool_call_result', array( $this, 'exit_protected_context' ), PHP_INT_MIN );
99 }
100
101 /**
102 * Whether this instance rebranded the adapter's default server.
103 *
104 * @var bool
105 */
106 private $claimed_default = false;
107
108 /**
109 * Rebrand the adapter's default server as Era's, and advertise our own tools
110 * alongside the dispatcher trio it already carries.
111 *
112 * Declines when another plugin already renamed this same server. Two plugins
113 * cannot both own it, and silently stealing it would break whichever loaded
114 * earlier — so we leave it and fall back to our own server in
115 * {@see self::register_server}.
116 *
117 * @param mixed $config Default server config from the factory.
118 * @return mixed
119 */
120 public function brand_default_server( $config ) {
121 if ( ! is_array( $config ) || ! self::is_enabled() ) {
122 return $config;
123 }
124 if ( ( $config['server_id'] ?? self::DEFAULT_SERVER_ID ) !== self::DEFAULT_SERVER_ID ) {
125 return $config;
126 }
127
128 $config['server_id'] = self::SERVER_ID;
129 $config['server_route'] = self::ROUTE;
130 $config['server_name'] = 'ZIP AI';
131 $config['server_description'] = 'Import AI-authored HTML into this WordPress site as native blocks, and run this site\'s abilities.';
132 $config['server_version'] = ZIPAI_MCP_VERSION;
133
134 // Keep whatever the factory put there (the discover/get-info/execute
135 // dispatcher) and add the import doors on top, so the write path is
136 // advertised rather than something an agent has to discover around.
137 // The factory's list is untyped from here, so narrow it before merging.
138 $existing = isset( $config['tools'] ) && is_array( $config['tools'] )
139 ? array_values( array_filter( $config['tools'], 'is_string' ) )
140 : array();
141
142 /**
143 * The SAME lockdown filter the fallback server applies. Advertised
144 * tools execute through their own permission_callback, never the
145 * execute-ability dispatcher, so `zip_ai_external_allowed_abilities`
146 * (which governs `meta.mcp.public`) cannot remove them; this filter is
147 * the one that narrows the advertised surface — and this rebrand path
148 * is the COMMON one (single-plugin installs), so without it here the
149 * documented lockdown was inert exactly where most sites would use it.
150 * An empty result leaves the server with no tools: locked, on purpose.
151 *
152 * @param string[] $tools Advertised tool ids.
153 */
154 $config['tools'] = array_values(
155 array_filter(
156 (array) apply_filters(
157 'zip_ai_external_mcp_tools',
158 array_values( array_unique( array_merge( $existing, External_Tool_Policy::ADVERTISED ) ) )
159 ),
160 'is_string'
161 )
162 );
163 $this->claimed_default = true;
164
165 return $config;
166 }
167
168 /**
169 * Raise the transport capability for our route only.
170 *
171 * Scoped by request route so other MCP servers on the site keep the adapter's
172 * own default. Returning the unfiltered value for anything else matters: this
173 * filter is global, and tightening a sibling plugin's server would break it.
174 *
175 * @param mixed $capability Capability the transport will check.
176 * @param mixed $context Request context (HttpRequestContext).
177 * @return mixed
178 */
179 public function route_capability( $capability, $context = null ) {
180 $route = '';
181 if ( is_object( $context ) && isset( $context->request ) && $context->request instanceof \WP_REST_Request ) {
182 $route = untrailingslashit( (string) $context->request->get_route() );
183 }
184 // Exact route or a sub-path of it, never a substring match — that would
185 // also catch a sibling server whose route merely contains ours (e.g.
186 // `/mcp/zip-ai-foo`) and break it by raising its capability.
187 $base = '/' . self::NAMESPACE . '/' . self::ROUTE;
188 if ( $route !== $base && ! str_starts_with( $route, $base . '/' ) ) {
189 return $capability;
190 }
191
192 return self::capability();
193 }
194
195 /**
196 * The capability this endpoint demands. Filterable so a site can deliberately
197 * widen it (e.g. to let editors import pages with their own application
198 * password) — every ability still enforces its own capability underneath, so
199 * lowering this cannot grant anything the caller lacks.
200 *
201 * @return string
202 */
203 public static function capability() {
204 $capability = apply_filters( 'zip_ai_external_mcp_capability', self::CAPABILITY );
205
206 return is_string( $capability ) && '' !== $capability ? $capability : self::CAPABILITY;
207 }
208
209 /**
210 * Whether this instance entered the protected-options MCP context.
211 *
212 * @var bool
213 */
214 private $entered_protected_context = false;
215
216 /**
217 * Enter the protected-options MCP context for a tool call on this server, and
218 * for a call on ANY server that runs one of this plugin's `zipai/*` abilities:
219 * they are public to every MCP server's dispatcher, so the server id alone
220 * would leave them unguarded there.
221 *
222 * McpTool::execute turns a Throwable from the ability into a WP_Error, so
223 * the result filter that leaves the context still runs; anything that ends
224 * the request first is cleared by the shutdown hook, which fails closed.
225 *
226 * @param mixed $args Tool arguments, or a WP_Error from an earlier filter.
227 * @param string $tool_name MCP tool name (slashes become hyphens).
228 * @param mixed $mcp_tool MCP tool instance.
229 * @param mixed $server The MCP server handling the call.
230 * @return mixed
231 */
232 public function enter_protected_context( $args, $tool_name, $mcp_tool, $server ) {
233 if ( is_wp_error( $args ) ) {
234 return $args;
235 }
236 $ours = $server instanceof \WP\MCP\Core\McpServer && self::SERVER_ID === $server->get_server_id();
237 if ( $ours || self::runs_zipai_ability( $args, (string) $tool_name, $mcp_tool ) ) {
238 Protected_Options_Filter::enter_mcp();
239 $this->entered_protected_context = true;
240 }
241 return $args;
242 }
243
244 /**
245 * Whether a tool call runs one of this plugin's abilities (a namespace
246 * `External_Tool_Policy::is_allowed()` admits): the tool's own ability, or
247 * the one the adapter's dispatcher (`mcp-adapter/execute-ability`) is asked
248 * to run. An adapter without the tool's ability name falls back to the tool
249 * name, where the ability's slash reads as a hyphen: matched by namespace
250 * prefix, so a namespace with a hyphen of its own is still read whole.
251 *
252 * @param mixed $args Tool arguments.
253 * @param string $tool_name MCP tool name.
254 * @param mixed $mcp_tool MCP tool instance.
255 * @return bool
256 */
257 private static function runs_zipai_ability( $args, $tool_name, $mcp_tool ) {
258 $context = is_object( $mcp_tool ) && method_exists( $mcp_tool, 'get_observability_context' ) ? $mcp_tool->get_observability_context() : array();
259 $ability = is_array( $context ) && isset( $context['ability_name'] ) && is_string( $context['ability_name'] ) ? $context['ability_name'] : '';
260 if ( 'mcp-adapter/execute-ability' === $ability || 'mcp-adapter-execute-ability' === $tool_name ) {
261 $ability = is_array( $args ) && isset( $args['ability_name'] ) && is_string( $args['ability_name'] ) ? $args['ability_name'] : '';
262 return '' !== $ability && External_Tool_Policy::is_allowed( $ability );
263 }
264 if ( '' !== $ability ) {
265 return External_Tool_Policy::is_allowed( $ability );
266 }
267 foreach ( External_Tool_Policy::ALLOWED_NAMESPACES as $namespace ) {
268 if ( str_starts_with( $tool_name, $namespace . '-' ) ) {
269 return true;
270 }
271 }
272 return false;
273 }
274
275 /**
276 * Leave the protected-options MCP context entered for this call.
277 *
278 * @param mixed $result The tool call result.
279 * @return mixed
280 */
281 public function exit_protected_context( $result ) {
282 if ( $this->entered_protected_context ) {
283 $this->entered_protected_context = false;
284 Protected_Options_Filter::exit_mcp();
285 }
286 return $result;
287 }
288
289 /**
290 * Surface a failure that the adapter's dispatcher hides.
291 *
292 * `mcp-adapter/execute-ability` wraps the target result as
293 * `{success: true, data: <inner>}`. This means "the dispatch worked". The
294 * inner value can itself be `{success: false, error: …}`. In that case the
295 * outer flag still reads true and `isError` is never set. An agent that
296 * checks the top level then marches past a real failure. For example, a
297 * plugin install that did not happen, or an import that was rejected.
298 *
299 * This method returns the inner envelope. The adapter then recognises the
300 * failure and marks the result as an error. The other sibling fields ride
301 * along in the message. The adapter's error path keeps only a string. The
302 * `suggestion` and `violations` fields let the agent self-correct. It does
303 * not need another round-trip.
304 *
305 * @param mixed $result The tool call result.
306 * @param array<mixed,mixed> $args Call arguments (unused).
307 * @param string $tool_name MCP tool name (slashes become hyphens).
308 * @return mixed
309 */
310 public function unmask_failed_dispatch( $result, $args, $tool_name ) {
311 unset( $args );
312 if ( ! is_array( $result ) ) {
313 return $result;
314 }
315
316 // The dispatcher reports "the dispatch worked" even when the ability it
317 // ran failed. Unwrap one level so the real envelope is judged below.
318 if ( 'mcp-adapter-execute-ability' === $tool_name && true === ( $result['success'] ?? null ) ) {
319 $inner = $result['data'] ?? null;
320 if ( is_array( $inner ) && false === ( $inner['success'] ?? null ) ) {
321 $result = $inner;
322 }
323 }
324
325 if ( false !== ( $result['success'] ?? null ) ) {
326 return $result;
327 }
328
329 $error = isset( $result['error'] ) && is_string( $result['error'] ) ? trim( $result['error'] ) : '';
330 if ( '' === $error ) {
331 return $result;
332 }
333
334 // Fold the remaining fields into the message. The adapter's error path
335 // keeps ONLY this string — `suggestion` and `data` (repair hints such as
336 // `violations`, and the import consent token) are otherwise dropped
337 // before the client ever sees them.
338 $suggestion = isset( $result['suggestion'] ) && is_string( $result['suggestion'] ) ? trim( $result['suggestion'] ) : '';
339 if ( '' !== $suggestion && ! str_contains( $error, $suggestion ) ) {
340 $error .= ' ' . $suggestion;
341 }
342 if ( isset( $result['data'] ) && is_array( $result['data'] ) && ! empty( $result['data'] ) ) {
343 $encoded = wp_json_encode( $result['data'] );
344 if ( false !== $encoded ) {
345 $error .= ' ' . $encoded;
346 }
347 }
348
349 return array(
350 'success' => false,
351 'error' => $error,
352 );
353 }
354
355 /**
356 * Whether the external endpoint is switched on for this site. Defaults to
357 * OFF; the endpoint is opt-in, so a site with no option row stays closed
358 * until an admin turns it on in the Connection screen.
359 *
360 * Stored as the STRING '1' / '0', never a boolean. `get_option()` cannot tell
361 * a stored `false` from a missing row — both arrive as `false` — so a string
362 * keeps an explicit ON distinguishable from a never-touched site.
363 *
364 * @return bool
365 */
366 public static function is_enabled() {
367 $stored = get_option( self::OPTION_KEY, self::DISABLED );
368 // A legacy boolean/empty value (written before this key was normalised to
369 // a string) is treated as an explicit OFF, matching what the admin chose.
370 $enabled = is_string( $stored ) ? self::ENABLED === $stored : (bool) $stored;
371
372 // The protected-options guard hooks filters added in MCP Adapter 0.5.0;
373 // an older standalone adapter would run every tool unguarded.
374 $adapter = defined( 'WP_MCP_VERSION' ) ? constant( 'WP_MCP_VERSION' ) : null;
375 if ( $enabled && is_string( $adapter ) && version_compare( $adapter, '0.5.0', '<' ) ) {
376 static $logged = false;
377 if ( ! $logged ) {
378 $logged = true;
379 Utils::debug_log( 'External MCP endpoint disabled', 'MCP Adapter ' . $adapter . ' is older than 0.5.0' );
380 }
381 $enabled = false;
382 }
383
384 /**
385 * Filter whether the external MCP endpoint is available. Return false to
386 * force it off site-wide regardless of the stored setting.
387 *
388 * @param bool $enabled Current state.
389 */
390 return (bool) apply_filters( 'zip_ai_external_mcp_enabled', $enabled );
391 }
392
393 /**
394 * Persist the on/off state. Kept here so the storage format lives with the
395 * reader that depends on it.
396 *
397 * @param bool $enabled Desired state.
398 * @return void
399 */
400 public static function set_enabled( bool $enabled ) {
401 update_option( self::OPTION_KEY, $enabled ? self::ENABLED : self::DISABLED );
402 }
403
404 /**
405 * The endpoint URL clients should be given.
406 *
407 * @return string
408 */
409 public static function server_url() {
410 return rest_url( self::NAMESPACE . '/' . self::ROUTE );
411 }
412
413 /**
414 * Register the curated server with the adapter.
415 *
416 * @param \WP\MCP\Core\McpAdapter $adapter The adapter that fired this hook.
417 * @return void
418 */
419 public function register_server( $adapter ) {
420 if ( ! self::is_enabled() ) {
421 return;
422 }
423 // The default server was successfully rebranded as ours — nothing to add.
424 if ( $this->claimed_default ) {
425 return;
426 }
427 // Someone else owns the default server. Stand up our own so the endpoint
428 // exists either way; it costs one extra route and keeps the URL stable.
429 if ( null !== $adapter->get_server( self::SERVER_ID ) ) {
430 return;
431 }
432
433 /**
434 * Filter the abilities exposed to external AI clients.
435 *
436 * Return a NARROWER list to lock a site down. Widening past Era's own
437 * abilities is not possible — the namespace bound is re-applied after
438 * this filter.
439 *
440 * @param string[] $tools Ability ids.
441 */
442 // Cast + filter rather than trust: a third-party filter is under no
443 // obligation to honour the documented shape, and a junk entry here would
444 // surface as an adapter-side registration failure.
445 $tools = array_values( array_filter( (array) apply_filters( 'zip_ai_external_mcp_tools', External_Tool_Policy::ADVERTISED ), 'is_string' ) );
446 if ( empty( $tools ) ) {
447 return;
448 }
449
450 $adapter->create_server(
451 self::SERVER_ID,
452 self::NAMESPACE,
453 self::ROUTE,
454 // Same identity the rebrand path uses — a fallback server must not
455 // present itself differently to a client.
456 'ZIP AI',
457 'Import AI-authored HTML into this WordPress site as native blocks, and run this site\'s abilities.',
458 ZIPAI_MCP_VERSION,
459 array( \WP\MCP\Transport\HttpTransport::class ),
460 null,
461 null,
462 $tools,
463 array(),
464 array(),
465 array( $this, 'check_transport_permission' )
466 );
467 }
468
469 /**
470 * Transport-level gate for the fallback server, where we DO pass a callback
471 * (so the `route_capability` filter is never consulted). Same bar as the
472 * rebrand path; each ability still enforces its own capability on top.
473 *
474 * @return bool
475 */
476 public function check_transport_permission() {
477 return is_user_logged_in() && current_user_can( self::capability() );
478 }
479 }
480