Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.52% covered (warning)
65.52%
19 / 29
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
WooCommerceAbilities
65.52% covered (warning)
65.52%
19 / 29
75.00% covered (warning)
75.00%
3 / 4
22.04
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 maybe_register_abilities
44.44% covered (danger)
44.44%
8 / 18
0.00% covered (danger)
0.00%
0 / 1
15.40
 check_ability_permission
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 is_blu_mcp_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare( strict_types=1 );
4
5namespace BLU\Integrations;
6
7/**
8 * Compatibility bridge for WooCommerce's native Abilities API integration.
9 *
10 * WooCommerce 10.3+ ships its own MCP server at `/wp-json/woocommerce/mcp` and gates
11 * the registration of its native abilities on a URI match against that exact route:
12 *
13 *   if ( ! \Automattic\WooCommerce\Internal\MCP\MCPAdapterProvider::is_mcp_request() ) {
14 *       return;
15 *   }
16 *
17 * Because the Bluehost MCP transport is mounted at `/blu/mcp`, WC's check fails and
18 * none of its native abilities (`woocommerce/products-list`, `woocommerce/orders-get`,
19 * etc.) get registered. The gateway then cannot see or invoke any of them — even
20 * though both the `woocommerce/` namespace and the `woocommerce-rest` category are
21 * whitelisted in {@see \BLU\Abilities\AbilityGateway::get_whitelisted_abilities()}.
22 *
23 * This class force-registers WC's abilities during `wp_abilities_api_init` by briefly
24 * spoofing `$_SERVER['REQUEST_URI']` so WC's gate passes. The original URI is restored
25 * inside a `finally` block before any other code observes the change.
26 *
27 * The fix is scoped: by default it only fires when the current request is being
28 * handled by the Bluehost MCP transport. Non-MCP requests (admin pages, regular
29 * REST calls, etc.) don't pay the controller-instantiation cost — which matches
30 * WC's own lazy-load intent.
31 *
32 * Filterable via `blu_mcp_register_woocommerce_abilities`. The default value
33 * passed in is the result of {@see self::is_blu_mcp_request()}.
34 */
35class WooCommerceAbilities {
36
37    /**
38     * Fully-qualified class name of WC's `AbilitiesRestBridge`.
39     *
40     * @var string
41     */
42    private const WC_BRIDGE_CLASS = '\\Automattic\\WooCommerce\\Internal\\Abilities\\AbilitiesRestBridge';
43
44    /**
45     * REQUEST_URI value that satisfies WC's `is_mcp_request()` check.
46     *
47     * Mirrors WC's `MCPAdapterProvider::MCP_NAMESPACE . '/' . MCPAdapterProvider::MCP_ROUTE`.
48     *
49     * @var string
50     */
51    private const SPOOF_URI = '/wp-json/woocommerce/mcp';
52
53    /**
54     * Path fragment that identifies a Bluehost MCP transport request.
55     *
56     * @var string
57     */
58    private const BLU_MCP_PATH = '/blu/mcp';
59
60    /**
61     * Hooks the force-registration callback on `wp_abilities_api_init` and the
62     * permission delegate on `woocommerce_check_rest_ability_permissions_for_method`.
63     */
64    public function __construct() {
65        // Priority 5 so we run before WC's own gated callback at default priority 10.
66        // WC's callback at priority 10 will see the (restored) original URI, its gate
67        // returns false, and it short-circuits — so registration happens exactly once.
68        add_action( 'wp_abilities_api_init', array( $this, 'maybe_register_abilities' ), 5 );
69
70        // WC's RestAbilityFactory::check_permission() runs this filter with a default
71        // of `false`; permission is only granted if some hook flips it to `true`. WC's
72        // own `WooCommerceRestTransport` adds that hook, but only when the transport is
73        // instantiated for `/woocommerce/mcp`. On `/blu/mcp` requests the transport
74        // never loads, the filter is never hooked, and every WC ability fails its
75        // permission check. We supply the missing hook here, scoped to the Bluehost
76        // transport, and delegate the actual authorization to the underlying REST
77        // controller's own permission_callback (which runs inside rest_do_request()).
78        add_filter( 'woocommerce_check_rest_ability_permissions_for_method', array( $this, 'check_ability_permission' ), 10, 3 );
79    }
80
81    /**
82     * Conditionally force-register WooCommerce's native abilities.
83     *
84     * Idempotent within a single request via a static guard so repeated firings of
85     * `wp_abilities_api_init` (e.g. from both the modern and the legacy
86     * `abilities_api_init` action) do not re-register and emit duplicate warnings.
87     *
88     * @return void
89     */
90    public function maybe_register_abilities(): void {
91        static $done = false;
92        if ( $done ) {
93            return;
94        }
95
96        $should_register = $this->is_blu_mcp_request();
97
98        /**
99         * Filter whether to force-register WooCommerce's native abilities on this request.
100         *
101         * Default is `true` when the current request URI contains `/blu/mcp` (so the
102         * gateway can see `woocommerce/*` abilities through `blu-list-abilities` /
103         * `blu-call-ability`), `false` otherwise (so non-MCP requests don't incur the
104         * controller-instantiation cost that WC's lazy-load was designed to avoid).
105         *
106         * @param bool $should_register Default decision based on the current request URI.
107         */
108        $should_register = apply_filters( 'blu_mcp_register_woocommerce_abilities', $should_register );
109        if ( ! $should_register ) {
110            return;
111        }
112
113        if ( ! class_exists( self::WC_BRIDGE_CLASS ) ) {
114            return;
115        }
116        if ( ! is_callable( array( self::WC_BRIDGE_CLASS, 'register_abilities' ) ) ) {
117            return;
118        }
119
120        $original_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : null;
121
122        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
123        $_SERVER['REQUEST_URI'] = self::SPOOF_URI;
124        try {
125            call_user_func( array( self::WC_BRIDGE_CLASS, 'register_abilities' ) );
126        } finally {
127            if ( null === $original_uri ) {
128                unset( $_SERVER['REQUEST_URI'] );
129            } else {
130                $_SERVER['REQUEST_URI'] = $original_uri;
131            }
132        }
133
134        $done = true;
135    }
136
137    /**
138     * Approve WooCommerce ability execution at the abilities-API layer for requests
139     * served by the Bluehost MCP transport. The underlying REST controller's own
140     * `permission_callback` still gates every dispatched request inside
141     * `RestAbilityFactory::execute_operation()`, so real authorization
142     * (e.g. `current_user_can( 'manage_woocommerce' )`) is enforced one layer down.
143     *
144     * Outside `/blu/mcp` we return `$allowed` unchanged so WC's own logic — including
145     * `WooCommerceRestTransport::check_ability_permission()` — is not overridden.
146     *
147     * @param bool   $allowed    Current decision propagated through the filter chain.
148     * @param string $method     HTTP method (GET, POST, PUT, PATCH, DELETE, OPTIONS).
149     * @param object $controller REST controller instance for the ability.
150     *
151     * @return bool
152     */
153    public function check_ability_permission( $allowed, $method, $controller ): bool {
154        unset( $method, $controller );
155
156        if ( $allowed ) {
157            return true;
158        }
159
160        if ( ! $this->is_blu_mcp_request() ) {
161            return (bool) $allowed;
162        }
163
164        /**
165         * Filter the per-method decision for WC ability execution on the Bluehost MCP transport.
166         *
167         * Default `true` defers the real authorization to the underlying REST controller's
168         * permission_callback, which runs inside rest_do_request(). Sites that want a stricter
169         * gate at the abilities-API layer can return `false` here.
170         *
171         * @param bool $allowed Whether to approve execution at the abilities-API layer.
172         */
173        return (bool) apply_filters( 'blu_mcp_woocommerce_ability_permission', true );
174    }
175
176    /**
177     * Whether the current request is being handled by the Bluehost MCP transport.
178     *
179     * @return bool
180     */
181    private function is_blu_mcp_request(): bool {
182        if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! is_string( $_SERVER['REQUEST_URI'] ) ) {
183            return false;
184        }
185        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
186        return false !== strpos( $_SERVER['REQUEST_URI'], self::BLU_MCP_PATH );
187    }
188}