Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.07% covered (danger)
15.07%
22 / 146
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_MCP_Settings
15.38% covered (danger)
15.38%
22 / 143
20.00% covered (danger)
20.00%
1 / 5
821.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_routes
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 permissions_check
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 get_mcp_settings
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 1
306
 update_mcp_settings
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
240
1<?php
2/**
3 * REST API endpoint for Jetpack AI MCP settings.
4 *
5 * GET  — proxies to WPCOM wpcom/v2/sites/{blog_id}/mcp-abilities (wpcom#205108) using
6 *         user token auth so that WPCOM can resolve the requesting user's account-level
7 *         MCP state. Reshapes the response into the { account, site, sites,
8 *         site_level_enabled_default } structure the client utilities expect.
9 * POST — proxies to WPCOM POST /sites/{blog_id}/mcp-abilities (user token auth) which
10 *         persists { site_level_enabled, abilities } to user settings via SettingsHelper,
11 *         keeping Jetpack and Calypso in sync.
12 *
13 * @package automattic/jetpack
14 */
15
16use Automattic\Jetpack\Connection\Client;
17use Automattic\Jetpack\Connection\Manager;
18
19if ( ! defined( 'ABSPATH' ) ) {
20    exit( 0 );
21}
22
23/**
24 * Class WPCOM_REST_API_V2_Endpoint_MCP_Settings
25 */
26class WPCOM_REST_API_V2_Endpoint_MCP_Settings extends WP_REST_Controller {
27    /**
28     * Namespace prefix.
29     *
30     * @var string
31     */
32    public $namespace = 'wpcom/v2';
33
34    /**
35     * Endpoint base route.
36     *
37     * @var string
38     */
39    public $rest_base = 'jetpack-ai/mcp-settings';
40
41    /**
42     * Constructor.
43     */
44    public function __construct() {
45        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
46    }
47
48    /**
49     * Register routes.
50     */
51    public function register_routes() {
52        register_rest_route(
53            $this->namespace,
54            '/' . $this->rest_base,
55            array(
56                array(
57                    'methods'             => WP_REST_Server::READABLE,
58                    'callback'            => array( $this, 'get_mcp_settings' ),
59                    'permission_callback' => array( $this, 'permissions_check' ),
60                ),
61                array(
62                    'methods'             => WP_REST_Server::EDITABLE,
63                    'callback'            => array( $this, 'update_mcp_settings' ),
64                    'permission_callback' => array( $this, 'permissions_check' ),
65                    'args'                => array(
66                        'mcp_abilities' => array(
67                            'type'     => 'object',
68                            'required' => true,
69                        ),
70                    ),
71                ),
72            )
73        );
74    }
75
76    /**
77     * Check permissions.
78     *
79     * @return bool|WP_Error
80     */
81    public function permissions_check() {
82        if ( ! current_user_can( 'manage_options' ) ) {
83            return new WP_Error(
84                'rest_forbidden',
85                __( 'You do not have permission to manage MCP settings.', 'jetpack' ),
86                array( 'status' => rest_authorization_required_code() )
87            );
88        }
89
90        return true;
91    }
92
93    /**
94     * Fetch MCP abilities from the WPCOM wpcom/v2/sites/{blog_id}/mcp-abilities endpoint.
95     *
96     * The WPCOM endpoint accepts user token auth and returns an array of abilities.
97     * The response is shaped to match the format the client utilities expect:
98     * { account: { tool-id: {...} }, site: { tool-id: {...} },
99     *   sites: [{ blog_id, site_level_enabled, abilities }],
100     *   site_level_enabled_default: bool }
101     *
102     * All state is owned by WPCOM and persisted via POST /sites/{blog_id}/mcp-abilities.
103     *
104     * @return WP_REST_Response|WP_Error
105     */
106    public function get_mcp_settings() {
107        $blog_id = Manager::get_site_id();
108
109        if ( is_wp_error( $blog_id ) ) {
110            return rest_ensure_response( $blog_id );
111        }
112
113        $response = Client::wpcom_json_api_request_as_user(
114            sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ),
115            '2',
116            array( 'method' => 'GET' ),
117            null,
118            'wpcom'
119        );
120
121        if ( is_wp_error( $response ) ) {
122            return $response;
123        }
124
125        $body = json_decode( wp_remote_retrieve_body( $response ), true );
126
127        // has_mcp_plan is set explicitly by the WPCOM endpoint using PaidPlanMiddleware.
128        // Fall back to 402/403 status handling for older WPCOM builds that predate the field.
129        $http_status  = wp_remote_retrieve_response_code( $response );
130        $has_mcp_plan = isset( $body['has_mcp_plan'] )
131            ? (bool) $body['has_mcp_plan']
132            : ! in_array( $http_status, array( 402, 403 ), true );
133
134        if ( ! $has_mcp_plan ) {
135            return rest_ensure_response(
136                array(
137                    'has_mcp_access' => false,
138                    'mcp_abilities'  => array(),
139                )
140            );
141        }
142
143        // Transform [ {name, title, readonly, site_context, enabled, …}, … ] → { name: {…}, … }.
144        // readonly and site_context are now provided by the WPCOM endpoint (wpcom#205108).
145        // Fall back to name-suffix heuristic for readonly only if the field is absent, so
146        // this endpoint remains functional against older WPCOM builds.
147        $account_abilities = new stdClass();
148        $site_abilities    = array();
149        if ( ! empty( $body['abilities'] ) && is_array( $body['abilities'] ) ) {
150            $account_abilities = array();
151            foreach ( $body['abilities'] as $ability ) {
152                if ( empty( $ability['name'] ) ) {
153                    continue;
154                }
155                if ( ! array_key_exists( 'readonly', $ability ) ) {
156                    $ability['readonly'] = ! (bool) preg_match( '/-(create|update|delete)$/i', $ability['name'] );
157                }
158                $account_abilities[ (string) $ability['name'] ] = $ability;
159
160                // `site` subset: tools marked site_context=true by WPCOM are the only ones
161                // relevant to the site-level settings UI. getSiteContextToolIds() in JS uses
162                // this to filter out account/notifications/billing/domains tools.
163                if ( ! empty( $ability['site_context'] ) ) {
164                    $site_abilities[ $ability['name'] ] = $ability;
165                }
166            }
167        }
168
169        // site_level_enabled comes directly from WPCOM — it is the authoritative effective
170        // state for this site (account default applied, site exceptions factored in).
171        // Fall back to the enabled-abilities heuristic only if the field is absent.
172        $site_level_enabled = isset( $body['site_level_enabled'] )
173            ? (bool) $body['site_level_enabled']
174            : ! empty(
175                array_filter(
176                    (array) $account_abilities,
177                    function ( $a ) {
178                        return ! empty( $a['enabled'] );
179                    }
180                )
181            );
182
183        // site_level_enabled_default mirrors Calypso: same value as site_level_enabled
184        // when derived from WPCOM (no per-site override concept at this layer).
185        $site_level_enabled_default = $site_level_enabled;
186
187        // Group descriptors (AIINT-469) — pass through if the WPCOM endpoint returns them.
188        $groups = array();
189        if ( isset( $body['groups'] ) && is_array( $body['groups'] ) ) {
190            $groups = $body['groups'];
191        }
192
193        // Use only the explicit per-site user overrides returned by WPCOM in user_overrides.abilities.
194        // These are the raw values stored via SettingsHelper — not the computed effective states
195        // (account defaults merged with overrides). Keeping only explicit overrides here lets the
196        // JS fall back to site_level_enabled as the default for any tool not yet overridden,
197        // matching Calypso's display behaviour (all tools on when site_level_enabled:true and no
198        // per-tool overrides exist).
199        $site_tool_abilities = array();
200        if ( isset( $body['user_overrides']['abilities'] ) && is_array( $body['user_overrides']['abilities'] ) ) {
201            foreach ( $body['user_overrides']['abilities'] as $name => $value ) {
202                $site_tool_abilities[ (string) $name ] = (bool) $value;
203            }
204        }
205
206        return rest_ensure_response(
207            array(
208                'has_mcp_access' => true,
209                'mcp_abilities'  => array(
210                    'account'                    => $account_abilities,
211                    'site'                       => $site_abilities,
212                    'sites'                      => array(
213                        array(
214                            'blog_id'            => (int) $blog_id,
215                            'site_level_enabled' => $site_level_enabled,
216                            'abilities'          => (object) $site_tool_abilities,
217                        ),
218                    ),
219                    'site_level_enabled_default' => $site_level_enabled_default,
220                    'groups'                     => $groups,
221                ),
222            )
223        );
224    }
225
226    /**
227     * Proxy mcp_abilities update to WPCOM POST /sites/{blog_id}/mcp-abilities.
228     *
229     * Accepts the sites[] format used by the client:
230     *   { sites: [{ blog_id, site_level_enabled?, abilities?: { tool_id: bool } }] }
231     *
232     * Extracts site_level_enabled and abilities from sites[0] and forwards them
233     * to the WPCOM endpoint which persists them to user settings via SettingsHelper.
234     * This keeps Jetpack and Calypso in sync — both read/write the same store.
235     *
236     * @param WP_REST_Request $request Full details about the request.
237     * @return WP_REST_Response|WP_Error
238     */
239    public function update_mcp_settings( $request ) {
240        $blog_id = Manager::get_site_id();
241
242        if ( is_wp_error( $blog_id ) ) {
243            return rest_ensure_response( $blog_id );
244        }
245
246        $incoming = $request->get_param( 'mcp_abilities' );
247
248        if ( is_object( $incoming ) ) {
249            $incoming = get_object_vars( $incoming );
250        } elseif ( ! is_array( $incoming ) ) {
251            $incoming = array();
252        }
253
254        // Unpack sites[0] into the flat format WPCOM POST /sites/{id}/mcp-abilities expects.
255        $wpcom_body = array();
256
257        if ( ! empty( $incoming['sites'] ) && is_array( $incoming['sites'] ) ) {
258            $site_update = $incoming['sites'][0];
259            if ( is_object( $site_update ) ) {
260                $site_update = get_object_vars( $site_update );
261            }
262
263            if ( isset( $site_update['site_level_enabled'] ) ) {
264                $wpcom_body['site_level_enabled'] = (bool) $site_update['site_level_enabled'];
265            }
266
267            if ( isset( $site_update['abilities'] ) ) {
268                $abilities  = is_object( $site_update['abilities'] )
269                    ? get_object_vars( $site_update['abilities'] )
270                    : (array) $site_update['abilities'];
271                $normalised = array();
272                foreach ( $abilities as $name => $value ) {
273                    $normalised[ $name ] = (bool) $value;
274                }
275                $wpcom_body['abilities'] = $normalised;
276            }
277        }
278
279        if ( ! empty( $wpcom_body ) ) {
280            $response = Client::wpcom_json_api_request_as_user(
281                sprintf( '/sites/%d/mcp-abilities', (int) $blog_id ),
282                '2',
283                array( 'method' => 'POST' ),
284                $wpcom_body,
285                'wpcom'
286            );
287
288            if ( is_wp_error( $response ) ) {
289                return $response;
290            }
291
292            $status = wp_remote_retrieve_response_code( $response );
293            if ( $status < 200 || $status >= 300 ) {
294                return new WP_Error(
295                    'wpcom_update_failed',
296                    __( 'Failed to save MCP settings on WordPress.com.', 'jetpack' ),
297                    array( 'status' => 502 )
298                );
299            }
300        }
301
302        return $this->get_mcp_settings();
303    }
304}
305
306wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_MCP_Settings' );