| @@ -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. |