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
← All changes | inc/api/external-mcp.php +98 -0 0.0.8 → 0.0.14 View file →
@@ -35,8 +35,10 @@
35 35
36 36 namespace ZipAI\MCP\Classes\Api;
37 37
38 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;
39 41
40 42 defined( 'ABSPATH' ) || exit;
41 43
42 44 /**
@@ -89,8 +91,12 @@
89 91 // masking below cannot be corrected — which is precisely why the two
90 92 // error-carrying import abilities are ADVERTISED as their own tools
91 93 // rather than reached through the dispatcher.
92 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 );
93 99 }
94 100
95 101 /**
96 102 * Whether this instance rebranded the adapter's default server.
@@ -200,8 +206,88 @@
200 206 return is_string( $capability ) && '' !== $capability ? $capability : self::CAPABILITY;
201 207 }
202 208
203 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 + /**
204 290 * Surface a failure that the adapter's dispatcher hides.
205 291 *
206 292 * `mcp-adapter/execute-ability` wraps the target result as
207 293 * `{success: true, data: <inner>}`. This means "the dispatch worked". The
@@ -281,8 +367,20 @@
281 367 $stored = get_option( self::OPTION_KEY, self::DISABLED );
282 368 // A legacy boolean/empty value (written before this key was normalised to
283 369 // a string) is treated as an explicit OFF, matching what the admin chose.
284 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 + }
285 383
286 384 /**
287 385 * Filter whether the external MCP endpoint is available. Return false to
288 386 * force it off site-wide regardless of the stored setting.