PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
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 (Beta) 0.0.8, at inc/api/external-mcp.php

382 lines 14.8 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
40 defined( 'ABSPATH' ) || exit;
41
42 /**
43 * Registers the curated, opt-in MCP server for external AI clients.
44 */
45 class External_Mcp {
46
47 const OPTION_KEY = 'zipai_external_mcp_enabled';
48 const DEFAULT_SERVER_ID = 'mcp-adapter-default-server';
49 const ENABLED = '1';
50 const DISABLED = '0';
51 /**
52 * Capability required to speak to this endpoint AT ALL.
53 *
54 * `manage_options`, because the import writes run under the site's BOUND
55 * ADMIN credential regardless of who called — admitting a lower role would
56 * let them cause admin-level writes. Note the bar cannot rest on how
57 * credentials are issued: the Connection screen is admin-only, but WordPress
58 * lets ANY user mint an application password from their own profile, so the
59 * transport check here is what actually keeps non-admins out.
60 *
61 * The adapter's own default here is `read` (any logged-in user, subscriber
62 * included), which would let a subscriber enumerate every public ability's
63 * name, description and input schema through `discover-abilities`.
64 */
65 const CAPABILITY = 'manage_options';
66
67 const SERVER_ID = 'zip-ai';
68 const ROUTE = 'zip-ai';
69 const NAMESPACE = 'mcp';
70
71 /**
72 * Hook the adapter's init.
73 *
74 * @return void
75 */
76 public function __construct() {
77 // Preferred path: rebrand the adapter's OWN default server as Era's rather
78 // than standing a second one beside it. One server, one URL, and the
79 // dispatcher trio plus auto-discovered resources/prompts come with it.
80 add_filter( 'mcp_adapter_default_server_config', array( $this, 'brand_default_server' ) );
81 // The default-server factory passes no transport permission callback, so
82 // the transport would fall back to the `read` capability — any subscriber
83 // with an Application Password. Raise it, scoped to our route only.
84 add_filter( 'mcp_adapter_default_transport_permission_user_capability', array( $this, 'route_capability' ), 10, 2 );
85 // Fallback only: runs after the factory (priority 20) and stands up our own
86 // server if another plugin had already claimed the default one.
87 add_action( 'mcp_adapter_init', array( $this, 'register_server' ), 20 );
88 // Only present in MCP Adapter 0.5.0+. On 0.4.1 there is no hook, so the
89 // masking below cannot be corrected — which is precisely why the two
90 // error-carrying import abilities are ADVERTISED as their own tools
91 // rather than reached through the dispatcher.
92 add_filter( 'mcp_adapter_tool_call_result', array( $this, 'unmask_failed_dispatch' ), 10, 3 );
93 }
94
95 /**
96 * Whether this instance rebranded the adapter's default server.
97 *
98 * @var bool
99 */
100 private $claimed_default = false;
101
102 /**
103 * Rebrand the adapter's default server as Era's, and advertise our own tools
104 * alongside the dispatcher trio it already carries.
105 *
106 * Declines when another plugin already renamed this same server. Two plugins
107 * cannot both own it, and silently stealing it would break whichever loaded
108 * earlier — so we leave it and fall back to our own server in
109 * {@see self::register_server}.
110 *
111 * @param mixed $config Default server config from the factory.
112 * @return mixed
113 */
114 public function brand_default_server( $config ) {
115 if ( ! is_array( $config ) || ! self::is_enabled() ) {
116 return $config;
117 }
118 if ( ( $config['server_id'] ?? self::DEFAULT_SERVER_ID ) !== self::DEFAULT_SERVER_ID ) {
119 return $config;
120 }
121
122 $config['server_id'] = self::SERVER_ID;
123 $config['server_route'] = self::ROUTE;
124 $config['server_name'] = 'ZIP AI';
125 $config['server_description'] = 'Import AI-authored HTML into this WordPress site as native blocks, and run this site\'s abilities.';
126 $config['server_version'] = ZIPAI_MCP_VERSION;
127
128 // Keep whatever the factory put there (the discover/get-info/execute
129 // dispatcher) and add the import doors on top, so the write path is
130 // advertised rather than something an agent has to discover around.
131 // The factory's list is untyped from here, so narrow it before merging.
132 $existing = isset( $config['tools'] ) && is_array( $config['tools'] )
133 ? array_values( array_filter( $config['tools'], 'is_string' ) )
134 : array();
135
136 /**
137 * The SAME lockdown filter the fallback server applies. Advertised
138 * tools execute through their own permission_callback, never the
139 * execute-ability dispatcher, so `zip_ai_external_allowed_abilities`
140 * (which governs `meta.mcp.public`) cannot remove them; this filter is
141 * the one that narrows the advertised surface — and this rebrand path
142 * is the COMMON one (single-plugin installs), so without it here the
143 * documented lockdown was inert exactly where most sites would use it.
144 * An empty result leaves the server with no tools: locked, on purpose.
145 *
146 * @param string[] $tools Advertised tool ids.
147 */
148 $config['tools'] = array_values(
149 array_filter(
150 (array) apply_filters(
151 'zip_ai_external_mcp_tools',
152 array_values( array_unique( array_merge( $existing, External_Tool_Policy::ADVERTISED ) ) )
153 ),
154 'is_string'
155 )
156 );
157 $this->claimed_default = true;
158
159 return $config;
160 }
161
162 /**
163 * Raise the transport capability for our route only.
164 *
165 * Scoped by request route so other MCP servers on the site keep the adapter's
166 * own default. Returning the unfiltered value for anything else matters: this
167 * filter is global, and tightening a sibling plugin's server would break it.
168 *
169 * @param mixed $capability Capability the transport will check.
170 * @param mixed $context Request context (HttpRequestContext).
171 * @return mixed
172 */
173 public function route_capability( $capability, $context = null ) {
174 $route = '';
175 if ( is_object( $context ) && isset( $context->request ) && $context->request instanceof \WP_REST_Request ) {
176 $route = untrailingslashit( (string) $context->request->get_route() );
177 }
178 // Exact route or a sub-path of it, never a substring match — that would
179 // also catch a sibling server whose route merely contains ours (e.g.
180 // `/mcp/zip-ai-foo`) and break it by raising its capability.
181 $base = '/' . self::NAMESPACE . '/' . self::ROUTE;
182 if ( $route !== $base && ! str_starts_with( $route, $base . '/' ) ) {
183 return $capability;
184 }
185
186 return self::capability();
187 }
188
189 /**
190 * The capability this endpoint demands. Filterable so a site can deliberately
191 * widen it (e.g. to let editors import pages with their own application
192 * password) — every ability still enforces its own capability underneath, so
193 * lowering this cannot grant anything the caller lacks.
194 *
195 * @return string
196 */
197 public static function capability() {
198 $capability = apply_filters( 'zip_ai_external_mcp_capability', self::CAPABILITY );
199
200 return is_string( $capability ) && '' !== $capability ? $capability : self::CAPABILITY;
201 }
202
203 /**
204 * Surface a failure that the adapter's dispatcher hides.
205 *
206 * `mcp-adapter/execute-ability` wraps the target result as
207 * `{success: true, data: <inner>}`. This means "the dispatch worked". The
208 * inner value can itself be `{success: false, error: …}`. In that case the
209 * outer flag still reads true and `isError` is never set. An agent that
210 * checks the top level then marches past a real failure. For example, a
211 * plugin install that did not happen, or an import that was rejected.
212 *
213 * This method returns the inner envelope. The adapter then recognises the
214 * failure and marks the result as an error. The other sibling fields ride
215 * along in the message. The adapter's error path keeps only a string. The
216 * `suggestion` and `violations` fields let the agent self-correct. It does
217 * not need another round-trip.
218 *
219 * @param mixed $result The tool call result.
220 * @param array<mixed,mixed> $args Call arguments (unused).
221 * @param string $tool_name MCP tool name (slashes become hyphens).
222 * @return mixed
223 */
224 public function unmask_failed_dispatch( $result, $args, $tool_name ) {
225 unset( $args );
226 if ( ! is_array( $result ) ) {
227 return $result;
228 }
229
230 // The dispatcher reports "the dispatch worked" even when the ability it
231 // ran failed. Unwrap one level so the real envelope is judged below.
232 if ( 'mcp-adapter-execute-ability' === $tool_name && true === ( $result['success'] ?? null ) ) {
233 $inner = $result['data'] ?? null;
234 if ( is_array( $inner ) && false === ( $inner['success'] ?? null ) ) {
235 $result = $inner;
236 }
237 }
238
239 if ( false !== ( $result['success'] ?? null ) ) {
240 return $result;
241 }
242
243 $error = isset( $result['error'] ) && is_string( $result['error'] ) ? trim( $result['error'] ) : '';
244 if ( '' === $error ) {
245 return $result;
246 }
247
248 // Fold the remaining fields into the message. The adapter's error path
249 // keeps ONLY this string — `suggestion` and `data` (repair hints such as
250 // `violations`, and the import consent token) are otherwise dropped
251 // before the client ever sees them.
252 $suggestion = isset( $result['suggestion'] ) && is_string( $result['suggestion'] ) ? trim( $result['suggestion'] ) : '';
253 if ( '' !== $suggestion && ! str_contains( $error, $suggestion ) ) {
254 $error .= ' ' . $suggestion;
255 }
256 if ( isset( $result['data'] ) && is_array( $result['data'] ) && ! empty( $result['data'] ) ) {
257 $encoded = wp_json_encode( $result['data'] );
258 if ( false !== $encoded ) {
259 $error .= ' ' . $encoded;
260 }
261 }
262
263 return array(
264 'success' => false,
265 'error' => $error,
266 );
267 }
268
269 /**
270 * Whether the external endpoint is switched on for this site. Defaults to
271 * OFF; the endpoint is opt-in, so a site with no option row stays closed
272 * until an admin turns it on in the Connection screen.
273 *
274 * Stored as the STRING '1' / '0', never a boolean. `get_option()` cannot tell
275 * a stored `false` from a missing row — both arrive as `false` — so a string
276 * keeps an explicit ON distinguishable from a never-touched site.
277 *
278 * @return bool
279 */
280 public static function is_enabled() {
281 $stored = get_option( self::OPTION_KEY, self::DISABLED );
282 // A legacy boolean/empty value (written before this key was normalised to
283 // a string) is treated as an explicit OFF, matching what the admin chose.
284 $enabled = is_string( $stored ) ? self::ENABLED === $stored : (bool) $stored;
285
286 /**
287 * Filter whether the external MCP endpoint is available. Return false to
288 * force it off site-wide regardless of the stored setting.
289 *
290 * @param bool $enabled Current state.
291 */
292 return (bool) apply_filters( 'zip_ai_external_mcp_enabled', $enabled );
293 }
294
295 /**
296 * Persist the on/off state. Kept here so the storage format lives with the
297 * reader that depends on it.
298 *
299 * @param bool $enabled Desired state.
300 * @return void
301 */
302 public static function set_enabled( bool $enabled ) {
303 update_option( self::OPTION_KEY, $enabled ? self::ENABLED : self::DISABLED );
304 }
305
306 /**
307 * The endpoint URL clients should be given.
308 *
309 * @return string
310 */
311 public static function server_url() {
312 return rest_url( self::NAMESPACE . '/' . self::ROUTE );
313 }
314
315 /**
316 * Register the curated server with the adapter.
317 *
318 * @param \WP\MCP\Core\McpAdapter $adapter The adapter that fired this hook.
319 * @return void
320 */
321 public function register_server( $adapter ) {
322 if ( ! self::is_enabled() ) {
323 return;
324 }
325 // The default server was successfully rebranded as ours — nothing to add.
326 if ( $this->claimed_default ) {
327 return;
328 }
329 // Someone else owns the default server. Stand up our own so the endpoint
330 // exists either way; it costs one extra route and keeps the URL stable.
331 if ( null !== $adapter->get_server( self::SERVER_ID ) ) {
332 return;
333 }
334
335 /**
336 * Filter the abilities exposed to external AI clients.
337 *
338 * Return a NARROWER list to lock a site down. Widening past Era's own
339 * abilities is not possible — the namespace bound is re-applied after
340 * this filter.
341 *
342 * @param string[] $tools Ability ids.
343 */
344 // Cast + filter rather than trust: a third-party filter is under no
345 // obligation to honour the documented shape, and a junk entry here would
346 // surface as an adapter-side registration failure.
347 $tools = array_values( array_filter( (array) apply_filters( 'zip_ai_external_mcp_tools', External_Tool_Policy::ADVERTISED ), 'is_string' ) );
348 if ( empty( $tools ) ) {
349 return;
350 }
351
352 $adapter->create_server(
353 self::SERVER_ID,
354 self::NAMESPACE,
355 self::ROUTE,
356 // Same identity the rebrand path uses — a fallback server must not
357 // present itself differently to a client.
358 'ZIP AI',
359 'Import AI-authored HTML into this WordPress site as native blocks, and run this site\'s abilities.',
360 ZIPAI_MCP_VERSION,
361 array( \WP\MCP\Transport\HttpTransport::class ),
362 null,
363 null,
364 $tools,
365 array(),
366 array(),
367 array( $this, 'check_transport_permission' )
368 );
369 }
370
371 /**
372 * Transport-level gate for the fallback server, where we DO pass a callback
373 * (so the `route_capability` filter is never consulted). Same bar as the
374 * rebrand path; each ability still enforces its own capability on top.
375 *
376 * @return bool
377 */
378 public function check_transport_permission() {
379 return is_user_logged_in() && current_user_can( self::capability() );
380 }
381 }
382