PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / trunk
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar vtrunk
3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 All 156 releases
notificationx / includes / MCP / Manager.php

Manager.php in NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar trunk, at includes/MCP/Manager.php

1,372 lines 71.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Orchestrates the NotificationX MCP module.
4 *
5 * Wires up the transport (REST route + pretty `/notificationx/mcp` endpoint),
6 * OAuth discovery documents and the `/notificationx/authorize` consent page,
7 * the admin-only management endpoints (connect / rotate / disconnect /
8 * self-test), and the "MCP" tab in NotificationX settings. The whole feature
9 * is gated behind a single `enable_mcp` setting that defaults to off.
10 *
11 * @package NotificationX\MCP
12 */
13
14 namespace NotificationX\MCP;
15
16 use NotificationX\GetInstance;
17 use NotificationX\Admin\Settings;
18 use NotificationX\Core\Rules;
19 use NotificationX\Abilities\Registrar;
20
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit;
23 }
24
25 /**
26 * @method static Manager get_instance( $args = null )
27 */
28 class Manager {
29
30 use GetInstance;
31
32 /**
33 * Boot the module. Called from the MCP bootstrap only when the runtime is
34 * capable (PHP version check) — see Bootstrap.
35 *
36 * @return void
37 */
38 public function init() {
39 // Abilities are always registered when the module boots; each is
40 // permission-checked individually and the transport is separately gated.
41 Registrar::get_instance()->boot();
42
43 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
44 add_action( 'parse_request', array( $this, 'handle_front_requests' ), 0 );
45
46 // Admin settings tab (pure PHP field schema; no JS rebuild needed).
47 add_filter( 'nx_settings_tab', array( $this, 'register_settings_tab' ), 20 );
48
49 // CSS + JS for the MCP panel (copy / reveal / revoke controls).
50 add_action( 'admin_print_footer_scripts', array( $this, 'print_panel_assets' ) );
51 }
52
53 /**
54 * Whether MCP access is switched on.
55 *
56 * @return bool
57 */
58 public function is_enabled() {
59 return (bool) Settings::get_instance()->get( 'settings.enable_mcp' );
60 }
61
62 /**
63 * The site's MCP connector URL.
64 *
65 * @return string
66 */
67 public function connector_url() {
68 return home_url( '/notificationx/mcp' );
69 }
70
71 /* --------------------------------------------------------------------- */
72 /* REST routes */
73 /* --------------------------------------------------------------------- */
74
75 /**
76 * Register the transport, OAuth and management routes.
77 *
78 * @return void
79 */
80 public function register_routes() {
81 $ns = 'notificationx/v1';
82
83 // MCP transport — auth happens inside the handler.
84 register_rest_route( $ns, '/mcp', array(
85 'methods' => 'POST',
86 'callback' => array( $this, 'rest_mcp' ),
87 'permission_callback' => '__return_true',
88 ) );
89
90 // OAuth: dynamic client registration + token endpoint (public).
91 register_rest_route( $ns, '/mcp/oauth/register', array(
92 'methods' => 'POST',
93 'callback' => array( $this, 'rest_oauth_register' ),
94 'permission_callback' => '__return_true',
95 ) );
96 register_rest_route( $ns, '/mcp/oauth/token', array(
97 'methods' => 'POST',
98 'callback' => array( $this, 'rest_oauth_token' ),
99 'permission_callback' => '__return_true',
100 ) );
101
102 // Management (admin only).
103 $admin = array( $this, 'admin_permission' );
104 register_rest_route( $ns, '/mcp/connection', array(
105 'methods' => 'GET',
106 'callback' => array( $this, 'rest_connection' ),
107 'permission_callback' => $admin,
108 ) );
109 register_rest_route( $ns, '/mcp/connect', array(
110 'methods' => 'POST',
111 'callback' => array( $this, 'rest_connect' ),
112 'permission_callback' => $admin,
113 ) );
114 register_rest_route( $ns, '/mcp/rotate', array(
115 'methods' => 'POST',
116 'callback' => array( $this, 'rest_rotate' ),
117 'permission_callback' => $admin,
118 ) );
119 register_rest_route( $ns, '/mcp/disconnect', array(
120 'methods' => 'POST',
121 'callback' => array( $this, 'rest_disconnect' ),
122 'permission_callback' => $admin,
123 ) );
124 register_rest_route( $ns, '/mcp/self-test', array(
125 'methods' => 'POST',
126 'callback' => array( $this, 'rest_self_test' ),
127 'permission_callback' => $admin,
128 ) );
129 register_rest_route( $ns, '/mcp/apps/revoke', array(
130 'methods' => 'POST',
131 'callback' => array( $this, 'rest_revoke_app' ),
132 'permission_callback' => $admin,
133 ) );
134 register_rest_route( $ns, '/mcp/apps', array(
135 'methods' => 'GET',
136 'callback' => array( $this, 'rest_list_apps' ),
137 'permission_callback' => $admin,
138 ) );
139 }
140
141 /**
142 * List the currently connected apps as JSON, so the Connected apps panel can
143 * refresh itself without a full page reload (an app may have been approved or
144 * detached since the page was rendered).
145 *
146 * @return \WP_REST_Response
147 */
148 public function rest_list_apps() {
149 $apps = array();
150 foreach ( $this->get_connected_apps() as $app ) {
151 $apps[] = array(
152 'type' => $app['type'],
153 'client_id' => $app['client_id'],
154 'name' => $app['name'],
155 'read_only' => (bool) $app['read_only'],
156 'scope_label' => $app['read_only'] ? __( 'Read-only', 'notificationx' ) : __( 'Read & write', 'notificationx' ),
157 );
158 }
159
160 return new \WP_REST_Response(
161 array(
162 'status' => 'success',
163 'count' => count( $apps ),
164 'apps' => $apps,
165 ),
166 200
167 );
168 }
169
170 /**
171 * Revoke a single connected app (pairing token or one OAuth client).
172 *
173 * @param \WP_REST_Request $request Request.
174 * @return \WP_REST_Response
175 */
176 public function rest_revoke_app( $request ) {
177 $params = $request->get_json_params() ?: $request->get_body_params();
178 $type = isset( $params['type'] ) ? sanitize_text_field( $params['type'] ) : '';
179
180 if ( 'pairing' === $type ) {
181 Pairing::get_instance()->disconnect();
182 } elseif ( 'oauth' === $type && ! empty( $params['client_id'] ) ) {
183 OAuth::get_instance()->revoke_client( sanitize_text_field( $params['client_id'] ) );
184 } else {
185 return new \WP_REST_Response( array( 'status' => 'error', 'message' => __( 'Nothing to revoke.', 'notificationx' ) ), 400 );
186 }
187
188 return new \WP_REST_Response( array( 'status' => 'success' ), 200 );
189 }
190
191 /**
192 * Management permission: administrators only.
193 *
194 * @return bool
195 */
196 public function admin_permission() {
197 return current_user_can( 'manage_options' );
198 }
199
200 /**
201 * MCP transport handler (REST).
202 *
203 * @param \WP_REST_Request $request Request.
204 * @return \WP_REST_Response
205 */
206 public function rest_mcp( $request ) {
207 return Server::get_instance()->handle( $request );
208 }
209
210 /**
211 * OAuth dynamic client registration handler.
212 *
213 * @param \WP_REST_Request $request Request.
214 * @return \WP_REST_Response|\WP_Error
215 */
216 public function rest_oauth_register( $request ) {
217 if ( ! $this->is_enabled() ) {
218 return new \WP_REST_Response( array( 'error' => 'mcp_disabled' ), 403 );
219 }
220 $result = OAuth::get_instance()->register_client( $request->get_json_params() ?: array() );
221 if ( is_wp_error( $result ) ) {
222 return new \WP_REST_Response( array( 'error' => $result->get_error_code(), 'error_description' => $result->get_error_message() ), 400 );
223 }
224 return new \WP_REST_Response( $result, 201 );
225 }
226
227 /**
228 * OAuth token handler.
229 *
230 * @param \WP_REST_Request $request Request.
231 * @return \WP_REST_Response
232 */
233 public function rest_oauth_token( $request ) {
234 if ( ! $this->is_enabled() ) {
235 return new \WP_REST_Response( array( 'error' => 'mcp_disabled' ), 403 );
236 }
237 // Token requests are form-encoded per OAuth; fall back to JSON.
238 $params = $request->get_body_params();
239 if ( empty( $params ) ) {
240 $params = $request->get_json_params() ?: array();
241 }
242 $result = OAuth::get_instance()->handle_token_request( $params );
243 if ( is_wp_error( $result ) ) {
244 $resp = new \WP_REST_Response( array( 'error' => $result->get_error_code(), 'error_description' => $result->get_error_message() ), 400 );
245 } else {
246 $resp = new \WP_REST_Response( $result, 200 );
247 }
248 $resp->header( 'Cache-Control', 'no-store' );
249 $resp->header( 'Pragma', 'no-cache' );
250 return $resp;
251 }
252
253 /**
254 * Connection status for the admin UI.
255 *
256 * @return \WP_REST_Response
257 */
258 public function rest_connection() {
259 return new \WP_REST_Response( $this->connection_state(), 200 );
260 }
261
262 /**
263 * Enable a pairing connection.
264 *
265 * @return \WP_REST_Response
266 */
267 public function rest_connect() {
268 Pairing::get_instance()->connect();
269 return new \WP_REST_Response( array( 'status' => 'success' ) + $this->connection_state(), 200 );
270 }
271
272 /**
273 * Rotate the pairing token.
274 *
275 * @return \WP_REST_Response
276 */
277 public function rest_rotate() {
278 Pairing::get_instance()->rotate();
279 return new \WP_REST_Response( array( 'status' => 'success' ) + $this->connection_state(), 200 );
280 }
281
282 /**
283 * Disconnect: drop the pairing token and revoke all OAuth grants.
284 *
285 * @return \WP_REST_Response
286 */
287 public function rest_disconnect() {
288 Pairing::get_instance()->disconnect();
289 OAuth::get_instance()->revoke_all();
290 return new \WP_REST_Response( array( 'status' => 'success' ), 200 );
291 }
292
293 /**
294 * Run the loopback self-test.
295 *
296 * @return \WP_REST_Response
297 */
298 public function rest_self_test() {
299 $result = SelfTest::get_instance()->run();
300 return new \WP_REST_Response( array( 'status' => $result['ok'] ? 'success' : 'error', 'message' => $result['message'] ) + $result, 200 );
301 }
302
303 /**
304 * Summarise the connection for the admin UI.
305 *
306 * @return array
307 */
308 protected function connection_state() {
309 $pairing = Pairing::get_instance();
310 return array(
311 'enabled' => $this->is_enabled(),
312 'connected' => $pairing->is_connected(),
313 'connector_url' => $this->connector_url(),
314 'token' => $pairing->site_token(),
315 );
316 }
317
318 /* --------------------------------------------------------------------- */
319 /* Front-end requests: pretty endpoint, discovery, authorize page */
320 /* --------------------------------------------------------------------- */
321
322 /**
323 * Intercept the MCP pretty endpoint, OAuth discovery docs and the
324 * authorize page from the front controller. Path-based so it works under
325 * any permalink structure without rewrite flushes.
326 *
327 * @param \WP $wp WordPress environment.
328 * @return void
329 */
330 public function handle_front_requests( $wp ) {
331 $path = $this->request_path();
332 if ( '' === $path ) {
333 return;
334 }
335
336 // OAuth discovery (also accept the path-suffixed RFC form).
337 if ( 0 === strpos( $path, '.well-known/oauth-authorization-server' ) ) {
338 $this->emit_json( OAuth::get_instance()->authorization_server_metadata() );
339 }
340 if ( 0 === strpos( $path, '.well-known/oauth-protected-resource' ) ) {
341 $this->emit_json( OAuth::get_instance()->protected_resource_metadata() );
342 }
343
344 // Pretty MCP endpoint.
345 if ( 'notificationx/mcp' === $path ) {
346 $this->handle_pretty_mcp();
347 }
348
349 // OAuth authorize consent page.
350 if ( 'notificationx/authorize' === $path ) {
351 $this->handle_authorize();
352 }
353 }
354
355 /**
356 * Handle the pretty MCP endpoint by delegating to the JSON-RPC server.
357 *
358 * @return void
359 */
360 protected function handle_pretty_mcp() {
361 // Only POST carries a JSON-RPC body; a GET is treated as a probe so
362 // clients discovering the endpoint still get a challenge.
363 $request = new \WP_REST_Request( 'POST', '/notificationx/v1/mcp' );
364 $auth = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- header validated downstream.
365 if ( $auth ) {
366 $request->set_header( 'authorization', $auth );
367 }
368 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- raw JSON-RPC body, parsed/validated by the server.
369 $request->set_body( file_get_contents( 'php://input' ) );
370
371 $response = Server::get_instance()->handle( $request );
372 $this->emit_rest_response( $response );
373 }
374
375 /**
376 * Render / process the OAuth authorize consent page.
377 *
378 * @return void
379 */
380 protected function handle_authorize() {
381 if ( ! $this->is_enabled() ) {
382 status_header( 404 );
383 exit;
384 }
385
386 // Require a logged-in administrator; bounce through wp-login if needed.
387 if ( ! is_user_logged_in() ) {
388 $current = ( is_ssl() ? 'https://' : 'http://' ) . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) ) . sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
389 wp_safe_redirect( wp_login_url( $current ) );
390 exit;
391 }
392 if ( ! current_user_can( 'manage_options' ) ) {
393 wp_die( esc_html__( 'You do not have permission to authorize an MCP connection.', 'notificationx' ) );
394 }
395
396 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- these are OAuth request params echoed back into a nonce-protected consent form; no state change on GET.
397 $params = wp_unslash( $_GET );
398 $request = OAuth::get_instance()->validate_authorize_request( $params );
399 if ( is_wp_error( $request ) ) {
400 wp_die( esc_html( $request->get_error_message() ) );
401 }
402
403 $is_post = ( 'POST' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) ) );
404
405 // Deny on POST (nonce-checked): bounce back to the client with the
406 // standard OAuth error so it can end the flow cleanly instead of the
407 // user landing on a dead browser tab.
408 if ( $is_post && isset( $_POST['nx_mcp_deny'] ) ) {
409 check_admin_referer( 'nx_mcp_authorize' );
410 $redirect = add_query_arg(
411 array(
412 'error' => 'access_denied',
413 'error_description' => rawurlencode( 'The user denied the authorization request.' ),
414 'state' => rawurlencode( $request['state'] ),
415 ),
416 $request['redirect_uri']
417 );
418 wp_redirect( $redirect ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- redirect_uri is validated against the registered client allow-list.
419 exit;
420 }
421
422 // Approve on POST (nonce-checked).
423 if ( $is_post && isset( $_POST['nx_mcp_authorize'] ) ) {
424 check_admin_referer( 'nx_mcp_authorize' );
425 $code = OAuth::get_instance()->issue_code( $request, get_current_user_id() );
426 $redirect = add_query_arg(
427 array(
428 'code' => rawurlencode( $code ),
429 'state' => rawurlencode( $request['state'] ),
430 ),
431 $request['redirect_uri']
432 );
433 wp_redirect( $redirect ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- redirect_uri is validated against the registered client allow-list.
434 exit;
435 }
436
437 $this->render_authorize_page( $request );
438 }
439
440 /**
441 * Output the consent form.
442 *
443 * @param array $request Validated authorize request.
444 * @return void
445 */
446 protected function render_authorize_page( $request ) {
447 $store = get_option( OAuth::OPTION, array() );
448 $client = isset( $store['clients'][ $request['client_id'] ] ) ? $store['clients'][ $request['client_id'] ] : array();
449 $name = ! empty( $client['client_name'] ) ? $client['client_name'] : $request['client_id'];
450 $scope = $request['scope'];
451
452 // What the granted scope actually permits, in plain language.
453 $read_only = OAuth::get_instance()->scope_is_read_only( $scope );
454
455 // The two ends of the connection: the client app and this site.
456 $client_host = (string) wp_parse_url( $request['redirect_uri'], PHP_URL_HOST );
457 $site_name = get_bloginfo( 'name' );
458 $site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
459
460 // Who is about to approve — everything the connection does is recorded
461 // as this user.
462 $user = wp_get_current_user();
463 $who_name = $user->display_name ? $user->display_name : $user->user_login;
464 $roles = (array) $user->roles;
465 $role_key = $roles ? (string) reset( $roles ) : '';
466 $role_lbl = '';
467 if ( $role_key ) {
468 $wp_roles = wp_roles();
469 if ( isset( $wp_roles->roles[ $role_key ]['name'] ) ) {
470 $role_lbl = translate_user_role( $wp_roles->roles[ $role_key ]['name'] );
471 }
472 }
473 $substr = function_exists( 'mb_substr' ) ? 'mb_substr' : 'substr';
474 $who_initial = strtoupper( $substr( $who_name, 0, 1 ) );
475 $client_initial = strtoupper( $substr( $name, 0, 1 ) );
476 // Show the connecting app's own mark when we recognise it; otherwise the initial.
477 $client_is_claude = ( false !== stripos( $name, 'claude' ) );
478
479 // The exact tools this grant unlocks, straight from the ability
480 // registry so the list can never drift from what the server exposes.
481 Registrar::get_instance()->boot();
482 $granted = array();
483 foreach ( Registrar::get_instance()->get_all() as $ability ) {
484 if ( $read_only && $ability->is_write() ) {
485 continue;
486 }
487 $granted[] = $ability;
488 }
489
490 $cap_label = $read_only ? __( 'Read only', 'notificationx' ) : __( 'Read & write', 'notificationx' );
491 $cap_text = $read_only
492 ? __( 'It can read your notifications, entries and analytics. It cannot create, change or delete anything.', 'notificationx' )
493 : __( 'It acts as you: anything it creates, edits or deletes is recorded under your account.', 'notificationx' );
494
495 // NotificationX brand mark (assets/admin/images/nx-icon.svg), inlined so
496 // the consent page never depends on a second asset request.
497 $nx_mark = '<svg viewBox="0 0 387 392" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g fill="none" fill-rule="evenodd"><g fill-rule="nonzero"><path d="m135.45 358.68h113.62c-2.05 13.15-27.83 29.91-49.81 32.3-25.34 2.75-56.03-12.6-63.81-32.3z" fill="#5614d5"/><path d="m372.31 305.79c-2.34-.2-4.71-.08-7.07-.08-5.61-.01-11.22 0-18.16 0 0-4.28 0-7.29 0-10.3-.01-46.66.17-93.32-.17-139.98-.08-10.54-1.03-21.24-3.12-31.56-17.4-85.97-103.85-140.06-188.98-118.65-67.97 17.09-116.9 79.04-116.62 149.48.17 42.42.02 84.84.01 127.26 0 3.84-.02 15.83-.04 23.74-5.18-.04-20.09-.13-25.3.18-7.73.45-12.92 6.43-12.82 14.09.1 7.46 5.04 12.77 12.63 13.45 2.11.19 4.24.15 6.36.15 115.71.04 231.43.07 347.14.09 2.12 0 4.25.03 6.36-.18 7.48-.75 12.61-6.25 12.75-13.53.13-7.37-5.42-13.51-12.97-14.16z" fill="#5614d5"/><g fill="#836eff"><circle cx="281.55" cy="255.92" r="15.49"/><path d="m295.67 140.1.24-.16c-.21-1.31-.39-2.65-.64-3.92-9.4-46.45-49.44-80.68-96.48-83.49-.06 0-.12-.01-.18-.01-2.02-.12-4.04-.2-6.08-.2-.05 0-.09 0-.14 0s-.09 0-.14 0c-2.04 0-4.07.08-6.08.2-.06 0-.12.01-.18.01-47.04 2.81-87.08 37.04-96.48 83.49-.26 1.27-.44 2.61-.64 3.92l.24.16c-.91 5.5-1.39 11.12-1.37 16.8.02 4.52.03 99.87.04 112.84l32.13 34.68c0-24.28-.01-133.85-.06-147.64-.13-32.6 22.96-62.09 54.91-70.12 2.65-.67 5.33-1.16 8.02-1.53.45-.06.89-.13 1.35-.18 1.02-.12 2.04-.21 3.05-.29 1.46-.1 2.92-.18 4.4-.19.27 0 .54-.02.81-.03.27 0 .54.02.81.03 1.48.01 2.94.09 4.4.19 1.02.08 2.04.17 3.05.29.45.05.9.12 1.35.18 2.69.37 5.37.86 8.02 1.53 31.94 8.03 55.04 37.53 54.91 70.12-.02 5.17-.03 50.29-.04 71.4l32.14-21.45c0-12.23.01-48.45.01-49.82.02-5.7-.45-11.31-1.37-16.81z"/></g></g><path d="m31.94 305.72c-6.36.13-12.74-.21-19.08.16-7.73.45-12.92 6.43-12.82 14.09.1 7.46 5.04 12.77 12.63 13.45 2.11.19 4.24.15 6.36.15 115.71.04 231.42.06 347.14.09 2.12 0 4.25.03 6.36-.18 7.48-.75 12.61-6.25 12.75-13.53.14-7.37-5.41-13.5-12.96-14.16-2.34-.2-4.71-.08-7.07-.08-5.61-.01-11.22 0-18.16 0 0-4.28 0-7.29 0-10.3-.01-40.67.11-81.34-.08-122l-215.39 143.62-78.04-84.22 33.47-30.79 51.67 55.6 204.48-136.36c-18.61-84.45-104.12-137.24-188.38-116.05-67.97 17.09-116.9 79.04-116.62 149.48.17 42.42.02 84.84.01 127.26 0 5.89.09 11.79-.05 17.67"/><path d="m346.91 155.42c.04 5.99.06 11.99.09 17.98l39.14-25.99-25.24-37.84-17.7 11.69c.19.87.42 1.72.6 2.59 2.08 10.33 3.04 21.04 3.11 31.57z" fill="#00f9ac" fill-rule="nonzero"/><path d="m87.05 202.03-33.47 30.79 78.04 84.22 215.38-143.63c-.03-5.99-.04-11.99-.09-17.98-.08-10.54-1.03-21.24-3.12-31.56-.18-.88-.4-1.73-.6-2.59l-204.47 136.35z"/><path d="m87.05 202.03-33.47 30.79 78.04 84.22 215.38-143.63c-.03-5.99-.04-11.99-.09-17.98-.08-10.54-1.03-21.24-3.12-31.56-.18-.88-.4-1.73-.6-2.59l-204.47 136.35z" fill="#21d8a3" fill-rule="nonzero" opacity=".9"/></g></svg>';
498
499 nocache_headers();
500 header( 'Content-Type: text/html; charset=utf-8' );
501 ?>
502 <!doctype html>
503 <html <?php language_attributes(); ?>>
504 <head>
505 <meta charset="<?php bloginfo( 'charset' ); ?>">
506 <meta name="viewport" content="width=device-width, initial-scale=1">
507 <meta name="robots" content="noindex,nofollow">
508 <title><?php esc_html_e( 'Authorize MCP connection', 'notificationx' ); ?></title>
509 <style>
510 :root{--nx:#6a4bff;--nx-dark:#5614d5;--ink:#1a1a2e;--muted:#5b6072;--line:#e7e7ef}
511 *{box-sizing:border-box}
512 body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;color:var(--ink);background:#f4f3fb;background:radial-gradient(1200px 600px at 50% -10%,#efe9ff 0%,#f4f3fb 45%,#f4f3fb 100%)}
513 .card{background:#fff;max-width:480px;width:100%;padding:32px 32px 28px;border-radius:20px;border:1px solid var(--line);box-shadow:0 18px 50px rgba(38,20,120,.10)}
514 .apps{display:flex;align-items:flex-start;justify-content:center;gap:8px;margin:4px 0 22px}
515 .app{width:132px;text-align:center}
516 .tile{width:64px;height:64px;margin:0 auto 10px;border-radius:16px;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 14px rgba(30,20,80,.10)}
517 .tile.client{background:#eef0f6;color:#3a4056;font-size:26px;font-weight:700}
518 .tile.client.has-mark{background:#fdf1ec}
519 .tile.client svg{width:38px;height:38px;display:block}
520 .tile.nx{background:#fff;border:1px solid var(--line)}
521 .tile.nx svg{width:42px;height:42px;display:block}
522 .app-name{font-size:14px;font-weight:600;line-height:1.3}
523 .app-host{font-size:12px;color:var(--muted);word-break:break-word;margin-top:2px}
524 .conn{flex:0 0 auto;align-self:center;margin-top:8px;display:flex;align-items:center;gap:6px;color:#b7b9c9}
525 .conn i{display:block;width:14px;height:0;border-top:2px dotted currentColor}
526 .conn .dot{width:26px;height:26px;border-radius:50%;border:1px solid var(--line);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px;background:#fff}
527 h1{font-size:19px;line-height:1.45;margin:0 0 20px;text-align:center;font-weight:600}
528 h1 strong{font-weight:700}
529 .cap{border-radius:14px;padding:16px 16px 14px;border:1px solid #e4defb;background:#f6f3ff}
530 .cap.ro{border-color:#dfe6f2;background:#f2f6fc}
531 .pill{display:inline-block;font-size:12px;font-weight:700;padding:5px 12px;border-radius:999px;background:var(--nx);color:#fff}
532 .cap.ro .pill{background:#3f6fd6}
533 .cap p{margin:11px 0 0;font-size:13px;line-height:1.55;color:#403c5c}
534 .who{display:flex;align-items:center;gap:10px;margin:16px 2px 0;font-size:13px;color:var(--muted)}
535 .avatar{width:30px;height:30px;border-radius:50%;background:#eef0f6;color:#3a4056;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex:0 0 auto}
536 .who b{color:var(--ink)}
537 details{margin-top:14px;border:1px solid var(--line);border-radius:12px;overflow:hidden}
538 summary{list-style:none;cursor:pointer;padding:13px 15px;font-size:14px;font-weight:600;display:flex;align-items:center;justify-content:space-between}
539 summary::-webkit-details-marker{display:none}
540 summary .chev{transition:transform .15s ease;color:var(--muted)}
541 details[open] summary .chev{transform:rotate(180deg)}
542 .abilities{margin:0;padding:2px 6px 8px;list-style:none}
543 .abilities li{padding:9px 9px;border-top:1px solid var(--line)}
544 .abilities .a-name{font-size:13px;font-weight:600}
545 .abilities .a-desc{font-size:12px;color:var(--muted);margin-top:2px;line-height:1.45}
546 .secured{display:flex;align-items:flex-start;gap:8px;margin:16px 2px 0;font-size:12px;color:var(--muted);line-height:1.5}
547 .secured svg{flex:0 0 auto;margin-top:1px}
548 .actions{display:flex;gap:12px;margin-top:22px}
549 button{flex:1;padding:13px;border-radius:11px;font-size:14px;font-weight:700;cursor:pointer;border:1px solid transparent}
550 .approve{background:var(--nx);color:#fff}
551 .approve:hover{background:var(--nx-dark)}
552 .deny{background:#fff;color:var(--ink);border-color:var(--line)}
553 .deny:hover{background:#f6f6fa}
554 </style>
555 </head>
556 <body>
557 <div class="card">
558 <div class="apps">
559 <div class="app">
560 <div class="tile client<?php echo $client_is_claude ? ' has-mark' : ''; ?>">
561 <?php if ( $client_is_claude ) : ?>
562 <svg viewBox="0 0 24 24" fill="none" stroke="#d97757" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><line x1="12" y1="3" x2="12" y2="21"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(30 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(60 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(90 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(120 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(150 12 12)"/></svg>
563 <?php else : ?>
564 <?php echo esc_html( $client_initial ); ?>
565 <?php endif; ?>
566 </div>
567 <div class="app-name"><?php echo esc_html( $name ); ?></div>
568 <?php if ( $client_host ) : ?><div class="app-host"><?php echo esc_html( $client_host ); ?></div><?php endif; ?>
569 </div>
570 <div class="conn" aria-hidden="true"><i></i><span class="dot">&rarr;</span><i></i></div>
571 <div class="app">
572 <div class="tile nx"><?php echo $nx_mark; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static inline brand SVG, no dynamic data. ?></div>
573 <div class="app-name">NotificationX</div>
574 <?php if ( $site_host ) : ?><div class="app-host"><?php echo esc_html( $site_host ); ?></div><?php endif; ?>
575 </div>
576 </div>
577
578 <h1>
579 <?php
580 printf(
581 /* translators: %1$s: client app name, %2$s: site name. */
582 esc_html__( '%1$s wants to work with your notifications on %2$s.', 'notificationx' ),
583 '<strong>' . esc_html( $name ) . '</strong>',
584 '<strong>' . esc_html( $site_name ? $site_name : $site_host ) . '</strong>'
585 );
586 ?>
587 </h1>
588
589 <div class="cap <?php echo $read_only ? 'ro' : ''; ?>">
590 <span class="pill"><?php echo esc_html( $cap_label ); ?></span>
591 <p><?php echo esc_html( $cap_text ); ?></p>
592 </div>
593
594 <div class="who">
595 <span class="avatar"><?php echo esc_html( $who_initial ); ?></span>
596 <span>
597 <?php
598 printf(
599 /* translators: %1$s: user display name, %2$s: user role. */
600 esc_html__( 'Signed in as %1$s%2$s', 'notificationx' ),
601 '<b>' . esc_html( $who_name ) . '</b>',
602 $role_lbl ? ' &middot; ' . esc_html( $role_lbl ) : ''
603 );
604 ?>
605 </span>
606 </div>
607
608 <?php if ( $granted ) : ?>
609 <details>
610 <summary>
611 <span>
612 <?php
613 printf(
614 /* translators: %1$s: client app name, %2$d: number of tools. */
615 esc_html__( 'What %1$s will be able to do (%2$d)', 'notificationx' ),
616 esc_html( $name ),
617 count( $granted )
618 );
619 ?>
620 </span>
621 <span class="chev">&#9662;</span>
622 </summary>
623 <ul class="abilities">
624 <?php foreach ( $granted as $ability ) : ?>
625 <li>
626 <div class="a-name"><?php echo esc_html( $ability->get_label() ); ?></div>
627 <div class="a-desc"><?php echo esc_html( $ability->get_description() ); ?></div>
628 </li>
629 <?php endforeach; ?>
630 </ul>
631 </details>
632 <?php endif; ?>
633
634 <div class="secured">
635 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
636 <span>
637 <?php esc_html_e( 'Secured with OAuth. You can revoke this app at any time under NotificationX → MCP.', 'notificationx' ); ?>
638 </span>
639 </div>
640
641 <form method="post">
642 <?php wp_nonce_field( 'nx_mcp_authorize' ); ?>
643 <div class="actions">
644 <button type="submit" class="deny" name="nx_mcp_deny" value="1"><?php esc_html_e( 'Deny', 'notificationx' ); ?></button>
645 <button type="submit" class="approve" name="nx_mcp_authorize" value="1"><?php esc_html_e( 'Approve', 'notificationx' ); ?></button>
646 </div>
647 </form>
648 </div>
649 </body>
650 </html>
651 <?php
652 exit;
653 }
654
655 /* --------------------------------------------------------------------- */
656 /* Settings tab (NotificationX admin flow) */
657 /* --------------------------------------------------------------------- */
658
659 /**
660 * Add the "MCP" tab to NotificationX settings.
661 *
662 * @param array $tabs Existing tabs.
663 * @return array
664 */
665 public function register_settings_tab( $tabs ) {
666 // If MCP is on, make sure a pairing token exists so the UI has one to show.
667 if ( $this->is_enabled() && ! Pairing::get_instance()->is_connected() ) {
668 Pairing::get_instance()->connect();
669 }
670
671 $tabs['tab-mcp'] = array(
672 'id' => 'tab-mcp',
673 'label' => __( 'MCP', 'notificationx' ),
674 'priority' => 45,
675 'fields' => $this->settings_fields(),
676 );
677
678 return $tabs;
679 }
680
681 /**
682 * Build the MCP settings field schema. The rich panels are server-rendered
683 * HTML delivered through quickbuilder `message` fields (html => true); the
684 * action buttons use quickbuilder `button` fields for the ajax + toast.
685 *
686 * @return array
687 */
688 protected function settings_fields() {
689 $enabled_rule = Rules::is( 'enable_mcp', true );
690
691 $fields = array(
692 'mcp_main_section' => array(
693 'name' => 'mcp_main_section',
694 'type' => 'section',
695 'label' => __( 'MCP Server', 'notificationx' ),
696 'fields' => array(
697 'mcp_hero' => array(
698 'name' => 'mcp_hero',
699 'type' => 'message',
700 'html' => true,
701 'message' => $this->hero_html(),
702 ),
703 'enable_mcp' => array(
704 'name' => 'enable_mcp',
705 'type' => 'toggle',
706 'default' => false,
707 'label' => __( 'Enable MCP access', 'notificationx' ),
708 'help' => __( 'When enabled and saved, approved AI assistants can connect to this site to manage notifications and read analytics.', 'notificationx' ),
709 ),
710 ),
711 ),
712
713 'mcp_connection_section' => array(
714 'name' => 'mcp_connection_section',
715 'type' => 'section',
716 'label' => __( 'Connection', 'notificationx' ),
717 'rules' => $enabled_rule,
718 'fields' => array(
719 'mcp_connection_html' => array(
720 'name' => 'mcp_connection_html',
721 'type' => 'message',
722 'html' => true,
723 'message' => $this->connection_html(),
724 ),
725 ),
726 ),
727
728 'mcp_clients_section' => array(
729 'name' => 'mcp_clients_section',
730 'type' => 'section',
731 'label' => __( 'Connect a client', 'notificationx' ),
732 'rules' => $enabled_rule,
733 'fields' => array(
734 'mcp_clients_html' => array(
735 'name' => 'mcp_clients_html',
736 'type' => 'message',
737 'html' => true,
738 'message' => $this->clients_html(),
739 ),
740 ),
741 ),
742
743 'mcp_apps_section' => array(
744 'name' => 'mcp_apps_section',
745 'type' => 'section',
746 'label' => __( 'Connected apps', 'notificationx' ),
747 'rules' => $enabled_rule,
748 'fields' => array(
749 'mcp_apps_html' => array(
750 'name' => 'mcp_apps_html',
751 'type' => 'message',
752 'html' => true,
753 'message' => $this->connected_apps_html(),
754 ),
755 ),
756 ),
757
758 'mcp_health_section' => array(
759 'name' => 'mcp_health_section',
760 'type' => 'section',
761 'label' => __( 'Connection health', 'notificationx' ),
762 'rules' => $enabled_rule,
763 'fields' => array(
764 'mcp_health_html' => array(
765 'name' => 'mcp_health_html',
766 'type' => 'message',
767 'html' => true,
768 'message' => $this->health_html(),
769 ),
770 ),
771 ),
772 );
773
774 return $fields;
775 }
776
777 /**
778 * Current status: off | setup | active.
779 *
780 * @return array [ state, label ]
781 */
782 protected function status() {
783 if ( ! $this->is_enabled() ) {
784 return array( 'off', __( 'Off', 'notificationx' ) );
785 }
786 if ( Pairing::get_instance()->is_connected() ) {
787 return array( 'active', __( 'Active', 'notificationx' ) );
788 }
789 return array( 'setup', __( 'Setup needed', 'notificationx' ) );
790 }
791
792 /**
793 * Hero header with the status badge.
794 *
795 * @return string
796 */
797 protected function hero_html() {
798 list( $state, $label ) = $this->status();
799 ob_start();
800 ?>
801 <div class="nx-mcp-hero">
802 <div class="nx-mcp-hero-icon">&#128268;</div>
803 <div class="nx-mcp-hero-body">
804 <h3 class="nx-mcp-hero-title">
805 <?php esc_html_e( 'MCP Server', 'notificationx' ); ?>
806 <span class="nx-mcp-badge nx-mcp-badge-<?php echo esc_attr( $state ); ?>"><?php echo esc_html( $label ); ?></span>
807 </h3>
808 <p class="nx-mcp-hero-text">
809 <?php esc_html_e( 'Connect NotificationX to Claude, ChatGPT, Cursor and other AI assistants through a built-in MCP server, so you can manage notifications and read analytics in plain language. It is off by default and only administrators can use it.', 'notificationx' ); ?>
810 </p>
811 <a class="nx-mcp-learn" href="<?php echo esc_url( 'https://notificationx.com/docs/mcp-in-notificationx' ); ?>" target="_blank" rel="noopener noreferrer">
812 <span class="nx-mcp-learn-text"><?php esc_html_e( 'Learn how it works', 'notificationx' ); ?></span>
813 <span class="nx-mcp-learn-arrow" aria-hidden="true">&rarr;</span>
814 </a>
815 </div>
816 </div>
817 <?php
818 return ob_get_clean();
819 }
820
821 /**
822 * Connector URL + token cards with copy/reveal controls.
823 *
824 * @return string
825 */
826 protected function connection_html() {
827 $url = $this->connector_url();
828 $token = Pairing::get_instance()->site_token();
829 ob_start();
830 ?>
831 <div class="nx-mcp-grid">
832 <div class="nx-mcp-card">
833 <span class="nx-mcp-card-label"><?php esc_html_e( 'Connector URL', 'notificationx' ); ?></span>
834 <div class="nx-mcp-copyrow">
835 <code class="nx-mcp-value"><?php echo esc_html( $url ); ?></code>
836 <button type="button" class="nx-mcp-copy" onclick="nxMcpCopy(this,'<?php echo esc_js( $url ); ?>')"><?php esc_html_e( 'Copy', 'notificationx' ); ?></button>
837 </div>
838 <p class="nx-mcp-hint"><?php esc_html_e( 'Add this URL as a custom connector in your AI client.', 'notificationx' ); ?></p>
839 </div>
840 <div class="nx-mcp-card">
841 <span class="nx-mcp-card-label"><?php esc_html_e( 'Connection token', 'notificationx' ); ?></span>
842 <div class="nx-mcp-copyrow">
843 <code class="nx-mcp-value nx-mcp-token" data-token="<?php echo esc_attr( $token ); ?>">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</code>
844 <button type="button" class="nx-mcp-copy" onclick="nxMcpReveal(this)"><?php esc_html_e( 'Show', 'notificationx' ); ?></button>
845 <button type="button" class="nx-mcp-copy" onclick="nxMcpCopy(this,'<?php echo esc_js( $token ); ?>')"><?php esc_html_e( 'Copy', 'notificationx' ); ?></button>
846 </div>
847 <p class="nx-mcp-hint"><?php esc_html_e( 'For token-based clients (ChatGPT, Cursor): send it as an Authorization: Bearer header. Keep it secret.', 'notificationx' ); ?></p>
848 </div>
849 </div>
850 <div class="nx-mcp-actions">
851 <button type="button" class="nx-mcp-btn nx-mcp-btn-secondary" onclick="nxMcpAction(this,'test',{success:'<?php echo esc_js( __( 'Connection test passed — the MCP server is reachable and exposing its tools.', 'notificationx' ) ); ?>'})"><?php esc_html_e( 'Test connection', 'notificationx' ); ?></button>
852 <button type="button" class="nx-mcp-btn nx-mcp-btn-ghost" onclick="nxMcpAction(this,'rotate',{confirm:'<?php echo esc_js( __( 'Reset the connection token? Existing clients will need the new token to reconnect.', 'notificationx' ) ); ?>',reload:true,success:'<?php echo esc_js( __( 'A new connection token was generated.', 'notificationx' ) ); ?>'})"><?php esc_html_e( 'Reset token', 'notificationx' ); ?></button>
853 </div>
854 <?php
855 return ob_get_clean();
856 }
857
858 /**
859 * Per-client setup cards.
860 *
861 * @return string
862 */
863 protected function clients_html() {
864 $url = esc_html( $this->connector_url() );
865 ob_start();
866 ?>
867 <div class="nx-mcp-clients">
868 <div class="nx-mcp-client">
869 <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/claude.svg' ); ?>" /> <?php esc_html_e( 'Claude', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'OAuth', 'notificationx' ); ?></span></div>
870 <ol class="nx-mcp-steps">
871 <li><?php esc_html_e( 'In Claude, add a custom connector.', 'notificationx' ); ?></li>
872 <li><?php esc_html_e( 'Paste the Connector URL above.', 'notificationx' ); ?></li>
873 <li><?php esc_html_e( 'Approve the connection when prompted — you sign in here, no token needed.', 'notificationx' ); ?></li>
874 </ol>
875 </div>
876 <div class="nx-mcp-client">
877 <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/chatgpt.svg' ); ?>" /> <?php esc_html_e( 'ChatGPT', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'Token', 'notificationx' ); ?></span></div>
878 <ol class="nx-mcp-steps">
879 <li><?php esc_html_e( 'Settings → Connectors → Add a custom connector.', 'notificationx' ); ?></li>
880 <li><?php /* translators: %s: connector URL */ printf( esc_html__( 'Use the URL %s.', 'notificationx' ), '<code>' . $url . '</code>' ); ?></li>
881 <li><?php esc_html_e( 'Provide the connection token as a Bearer credential.', 'notificationx' ); ?></li>
882 </ol>
883 </div>
884 <div class="nx-mcp-client">
885 <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/cursor.svg' ); ?>" /> <?php esc_html_e( 'Cursor &amp; others', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'Token', 'notificationx' ); ?></span></div>
886 <ol class="nx-mcp-steps">
887 <li><?php esc_html_e( 'Add an MCP server with the Connector URL above.', 'notificationx' ); ?></li>
888 <li><?php esc_html_e( 'Set the Authorization header to: Bearer <token>.', 'notificationx' ); ?></li>
889 <li><?php esc_html_e( 'Confirm the install when the client asks.', 'notificationx' ); ?></li>
890 </ol>
891 </div>
892 </div>
893 <?php
894 return ob_get_clean();
895 }
896
897 /**
898 * The list of currently connected AI apps (pairing token + OAuth clients).
899 *
900 * @return string
901 */
902 /**
903 * The currently connected apps (pairing token + active OAuth clients). Shared
904 * by the rendered panel and the /mcp/apps endpoint so the two cannot drift.
905 *
906 * @return array[] Each: type, client_id, name, read_only.
907 */
908 protected function get_connected_apps() {
909 $apps = array();
910
911 // Only list the token connection once a client has actually used it —
912 // the token existing on its own is not a "connected app".
913 $pairing = Pairing::get_instance();
914 $pstate = $pairing->state();
915 if ( $pairing->is_connected() && ! empty( $pstate['last_used'] ) ) {
916 $apps[] = array(
917 'type' => 'pairing',
918 'client_id' => '',
919 'name' => __( 'Token connection (ChatGPT / Cursor / manual)', 'notificationx' ),
920 'read_only' => $pairing->is_read_only(),
921 );
922 }
923 foreach ( OAuth::get_instance()->list_active_clients() as $client ) {
924 $apps[] = array(
925 'type' => 'oauth',
926 'client_id' => $client['client_id'],
927 'name' => $client['name'],
928 'read_only' => ! empty( $client['read_only'] ),
929 );
930 }
931
932 return $apps;
933 }
934
935 protected function connected_apps_html() {
936 $apps = $this->get_connected_apps();
937
938 ob_start();
939 ?>
940 <div class="nx-mcp-apps-head">
941 <span class="nx-mcp-apps-hint"><?php esc_html_e( 'Apps you have approved. Refresh to pick up a new or detached connection.', 'notificationx' ); ?></span>
942 <button type="button" class="nx-mcp-btn nx-mcp-btn-ghost nx-mcp-btn-sm nx-mcp-refresh-apps"><?php esc_html_e( 'Refresh', 'notificationx' ); ?></button>
943 </div>
944 <div id="nx-mcp-apps-wrap">
945 <?php
946 if ( empty( $apps ) ) {
947 echo '<p class="nx-mcp-empty">' . esc_html__( 'No AI clients are connected yet.', 'notificationx' ) . '</p>';
948 } else {
949 echo '<div class="nx-mcp-apps">';
950 foreach ( $apps as $app ) {
951 $scope_class = $app['read_only'] ? 'nx-mcp-scope-ro' : 'nx-mcp-scope-rw';
952 $scope_label = $app['read_only'] ? __( 'Read-only', 'notificationx' ) : __( 'Read & write', 'notificationx' );
953 ?>
954 <div class="nx-mcp-app" data-nx-key="<?php echo esc_attr( $app['type'] . ':' . $app['client_id'] ); ?>">
955 <div class="nx-mcp-app-info">
956 <strong><?php echo esc_html( $app['name'] ); ?></strong>
957 <span class="nx-mcp-scope <?php echo esc_attr( $scope_class ); ?>"><?php echo esc_html( $scope_label ); ?></span>
958 </div>
959 <button type="button" class="nx-mcp-revoke" onclick="nxMcpRevoke(this,'<?php echo esc_js( $app['type'] ); ?>','<?php echo esc_js( $app['client_id'] ); ?>')"><?php esc_html_e( 'Revoke', 'notificationx' ); ?></button>
960 </div>
961 <?php
962 }
963 echo '</div>';
964 }
965 ?>
966 </div>
967 <?php
968 return ob_get_clean();
969 }
970
971 /**
972 * Connection health panel.
973 *
974 * @return string
975 */
976 protected function health_html() {
977 $secure = is_ssl();
978 ob_start();
979 ?>
980 <div class="nx-mcp-health">
981 <div class="nx-mcp-health-row">
982 <span class="nx-mcp-dot <?php echo $secure ? 'nx-mcp-dot-good' : 'nx-mcp-dot-warn'; ?>"></span>
983 <?php if ( $secure ) : ?>
984 <?php esc_html_e( 'Secure connection (HTTPS) is on.', 'notificationx' ); ?>
985 <?php else : ?>
986 <?php esc_html_e( 'This site is not served over HTTPS. Token clients work, but hosted clients like Claude require an HTTPS site to connect.', 'notificationx' ); ?>
987 <?php endif; ?>
988 </div>
989 <div class="nx-mcp-health-row"><span class="nx-mcp-dot nx-mcp-dot-good"></span><?php /* translators: %s: protocol version */ printf( esc_html__( 'MCP protocol version %s.', 'notificationx' ), esc_html( Server::PROTOCOL_VERSION ) ); ?></div>
990 <div class="nx-mcp-health-row"><span class="nx-mcp-dot nx-mcp-dot-good"></span><?php esc_html_e( 'Endpoint:', 'notificationx' ); ?> <code><?php echo esc_html( $this->connector_url() ); ?></code></div>
991 <p class="nx-mcp-hint"><?php esc_html_e( 'Use “Test connection” above to verify the server end-to-end.', 'notificationx' ); ?></p>
992 </div>
993 <div class="nx-mcp-danger">
994 <div class="nx-mcp-danger-text">
995 <strong><?php esc_html_e( 'Disconnect all', 'notificationx' ); ?></strong>
996 <span><?php esc_html_e( 'Revoke every connection and OAuth grant. All clients will need to reconnect.', 'notificationx' ); ?></span>
997 </div>
998 <button type="button" class="nx-mcp-btn nx-mcp-btn-danger" onclick="nxMcpAction(this,'disconnect',{confirm:'<?php echo esc_js( __( 'Disconnect all clients? Every connection will be revoked.', 'notificationx' ) ); ?>',reload:true,success:'<?php echo esc_js( __( 'All MCP connections have been revoked.', 'notificationx' ) ); ?>'})"><?php esc_html_e( 'Disconnect all clients', 'notificationx' ); ?></button>
999 </div>
1000 <?php
1001 return ob_get_clean();
1002 }
1003
1004 /**
1005 * Print the MCP panel CSS + JS on the NotificationX settings page.
1006 * (The onclick handlers in the rendered HTML reference these globals.)
1007 *
1008 * @return void
1009 */
1010 public function print_panel_assets() {
1011 // The NotificationX admin is a single-page app (BrowserRouter): moving
1012 // between its screens — including into Settings → MCP — is client-side, so
1013 // admin_print_footer_scripts fires only on the first full page load,
1014 // whatever NX screen that happened to be. Print the panel CSS/JS on every
1015 // NotificationX admin page (slug prefixed "nx-"), not just nx-settings, so
1016 // the styles/handlers are already on the document when the MCP tab renders
1017 // after a client-side navigation. Otherwise the panel shows unstyled until
1018 // a manual reload.
1019 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check.
1020 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
1021 if ( ! is_admin() || 0 !== strpos( $page, 'nx-' ) ) {
1022 return;
1023 }
1024 $nonce = wp_create_nonce( 'wp_rest' );
1025 $urls = array(
1026 'test' => esc_url_raw( rest_url( 'notificationx/v1/mcp/self-test' ) ),
1027 'rotate' => esc_url_raw( rest_url( 'notificationx/v1/mcp/rotate' ) ),
1028 'disconnect' => esc_url_raw( rest_url( 'notificationx/v1/mcp/disconnect' ) ),
1029 'revoke' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps/revoke' ) ),
1030 'apps' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps' ) ),
1031 );
1032 $i18n = array(
1033 'revoke' => __( 'Revoke', 'notificationx' ),
1034 'empty' => __( 'No AI clients are connected yet.', 'notificationx' ),
1035 'refreshFailed' => __( 'Could not refresh the connected apps.', 'notificationx' ),
1036 'revokeConfirm' => __( 'Revoke this connection? The client will need to reconnect.', 'notificationx' ),
1037 // Refresh outcomes: say what actually changed, not just a count.
1038 'noneStill' => __( 'No apps connected yet.', 'notificationx' ),
1039 'upToDate' => __( 'Up to date — nothing changed.', 'notificationx' ),
1040 'addedOne' => __( '1 new app connected.', 'notificationx' ),
1041 /* translators: %d: number of newly connected apps. */
1042 'addedMany' => __( '%d new apps connected.', 'notificationx' ),
1043 'removedOne' => __( '1 app disconnected.', 'notificationx' ),
1044 /* translators: %d: number of disconnected apps. */
1045 'removedMany' => __( '%d apps disconnected.', 'notificationx' ),
1046 'changed' => __( 'Connected apps updated.', 'notificationx' ),
1047 );
1048 ?>
1049 <style id="nx-mcp-panel-css">
1050 .nx-mcp-hero{display:flex;gap:14px;align-items:flex-start}
1051 .nx-mcp-hero-icon{font-size:26px;line-height:1}
1052 .nx-mcp-hero-title{margin:0 0 6px;font-size:18px;display:flex;align-items:center;gap:10px}
1053 .nx-mcp-hero-text{margin:0;color:#50575e;max-width:640px}
1054 .nx-mcp-learn{display:inline-flex;align-items:center;gap:5px;margin-top:10px;color:#6a4bff;font-size:13px;font-weight:600}
1055 /* The message-field CSS (#notificationx .wprf-message p a) underlines the
1056 whole anchor at rest, which draws a line under the arrow too. Override
1057 it in every state (!important beats that #id rule) and underline only
1058 the text span on hover. */
1059 .nx-mcp-learn,.nx-mcp-learn:link,.nx-mcp-learn:visited,.nx-mcp-learn:hover,.nx-mcp-learn:focus,.nx-mcp-learn:active{text-decoration:none!important}
1060 .nx-mcp-learn .nx-mcp-learn-text{text-decoration:none}
1061 .nx-mcp-learn:hover .nx-mcp-learn-text{text-decoration:underline}
1062 .nx-mcp-learn-arrow{display:inline-block;transition:transform .2s}
1063 .nx-mcp-learn:hover .nx-mcp-learn-arrow{transform:translateX(3px)}
1064 .nx-mcp-badge{font-size:11px;font-weight:600;padding:2px 10px;border-radius:999px;text-transform:uppercase;letter-spacing:.02em}
1065 .nx-mcp-badge-off{background:#e2e4e7;color:#50575e}
1066 .nx-mcp-badge-active{background:#e5f6ea;color:#1a7f37}
1067 .nx-mcp-badge-setup{background:#fcf3e3;color:#996800}
1068 /* Enable toggle: keep label + switch on one row (no fixed 200px label
1069 column gap) and let the help text span full-width, left-aligned. */
1070 .wprf-name-enable_mcp{display:flex;flex-wrap:wrap;align-items:center}
1071 .wprf-name-enable_mcp .wprf-control-label{width:auto!important;flex:0 0 auto!important;margin:0 12px 0 0!important}
1072 .wprf-name-enable_mcp .wprf-control-field{display:contents}
1073 .wprf-name-enable_mcp .wprf-toggle-wrap{order:2}
1074 .wprf-name-enable_mcp .wprf-help{order:3;flex-basis:100%;width:100%;margin:8px 0 0!important}
1075 .nx-mcp-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
1076 @media(max-width:782px){.nx-mcp-grid{grid-template-columns:1fr}}
1077 .nx-mcp-card{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff}
1078 .nx-mcp-card-label{display:block;font-weight:600;font-size:12px;color:#50575e;text-transform:uppercase;letter-spacing:.03em;margin-bottom:8px}
1079 .nx-mcp-copyrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
1080 .nx-mcp-value{background:#f6f7f7;border:1px solid #e0e0e0;border-radius:6px;padding:6px 10px;font-size:12px;flex:1;min-width:0;overflow:auto;white-space:nowrap}
1081 .nx-mcp-copy{cursor:pointer;border:1px solid #c3c4c7;background:#f6f7f7;border-radius:6px;padding:6px 12px;font-size:12px;font-weight:600;color:#2c3338}
1082 .nx-mcp-copy:hover{background:#eef0f1}
1083 .nx-mcp-hint{margin:8px 0 0;color:#787c82;font-size:12px}
1084 .nx-mcp-clients{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
1085 @media(max-width:960px){.nx-mcp-clients{grid-template-columns:1fr}}
1086 .nx-mcp-client{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff}
1087 .nx-mcp-client-name{font-weight:600;display:flex;align-items:center;gap:8px;margin-bottom:8px}
1088 .nx-mcp-tag{font-size:10px;font-weight:600;background:#f0eefe;color:#6a4bff;padding:2px 8px;border-radius:999px;text-transform:uppercase}
1089 .nx-mcp-steps{margin:0;padding-left:18px;color:#50575e;font-size:13px;line-height:1.7}
1090 .nx-mcp-apps-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;flex-wrap:wrap}
1091 .nx-mcp-apps-hint{color:#787c82;font-size:12px}
1092 .nx-mcp-btn-sm{padding:5px 12px;font-size:12px}
1093 /* Once moved into the section heading bar, sit flush right on that row. */
1094 .wprf-section-title .nx-mcp-refresh-apps{margin-left:auto}
1095 .nx-mcp-apps-head:empty{display:none;margin:0}
1096 .nx-mcp-apps{display:flex;flex-direction:column;gap:10px}
1097 .nx-mcp-app{display:flex;justify-content:space-between;align-items:center;border:1px solid #e0e0e0;border-radius:8px;padding:10px 14px;background:#fff}
1098 .nx-mcp-app-info{display:flex;align-items:center;gap:10px}
1099 .nx-mcp-scope{font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px}
1100 .nx-mcp-scope-ro{background:#eef0f1;color:#50575e}
1101 .nx-mcp-scope-rw{background:#e5f6ea;color:#1a7f37}
1102 .nx-mcp-revoke{cursor:pointer;border:1px solid #d63638;background:#fff;color:#d63638;border-radius:6px;padding:5px 12px;font-size:12px;font-weight:600}
1103 .nx-mcp-revoke:hover{background:#d63638;color:#fff}
1104 .nx-mcp-empty{color:#787c82;font-style:italic}
1105 .nx-mcp-health{display:flex;flex-direction:column;gap:8px}
1106 .nx-mcp-health-row{display:flex;align-items:center;gap:8px;color:#2c3338;font-size:13px}
1107 .nx-mcp-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
1108 .nx-mcp-dot-good{background:#1a7f37}
1109 .nx-mcp-dot-warn{background:#dba617}
1110 /* Client icons are <img> tags pointing at real SVG files: the card HTML is
1111 kses-filtered, which strips <svg> and rejects data: URIs in src/style. */
1112 .nx-mcp-client-ic{width:20px;height:20px;flex:none;display:inline-block;vertical-align:middle}
1113 .nx-mcp-actions{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap}
1114 .nx-mcp-btn{cursor:pointer;border-radius:6px;padding:8px 16px;font-size:13px;font-weight:600;border:1px solid transparent;line-height:1.2}
1115 .nx-mcp-btn[disabled]{opacity:.6;cursor:default}
1116 .nx-mcp-btn-secondary{background:#6a4bff;color:#fff}
1117 .nx-mcp-btn-secondary:hover{background:#583fd6}
1118 .nx-mcp-btn-ghost{background:#fff;color:#2c3338;border-color:#c3c4c7}
1119 .nx-mcp-btn-ghost:hover{background:#f6f7f7}
1120 .nx-mcp-btn-danger{background:#d63638;color:#fff;border-color:#d63638}
1121 .nx-mcp-btn-danger:hover{background:#b32d2e}
1122 .nx-mcp-danger{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-top:16px;padding:14px 16px;border:1px solid #f0c4c4;background:#fcf0f0;border-radius:10px;flex-wrap:wrap}
1123 .nx-mcp-danger-text{display:flex;flex-direction:column;gap:2px}
1124 .nx-mcp-danger-text strong{color:#8a1f21}
1125 .nx-mcp-danger-text span{color:#a15b5b;font-size:12px}
1126 .nx-mcp-toast{position:fixed;bottom:28px;right:28px;z-index:100001;padding:12px 18px;border-radius:8px;color:#fff;font-size:13px;font-weight:600;box-shadow:0 8px 28px rgba(0,0,0,.2);opacity:0;transform:translateY(12px);transition:opacity .28s,transform .28s;max-width:380px}
1127 .nx-mcp-toast-in{opacity:1;transform:translateY(0)}
1128 .nx-mcp-toast-success{background:#1a7f37}
1129 .nx-mcp-toast-error{background:#d63638}
1130 </style>
1131 <script id="nx-mcp-panel-js">
1132 window.nxMcpData = { urls: <?php echo wp_json_encode( $urls ); ?>, nonce: <?php echo wp_json_encode( $nonce ); ?>, i18n: <?php echo wp_json_encode( $i18n ); ?> };
1133 window.nxMcpToast = function(type, msg){
1134 var t = document.createElement('div');
1135 t.className = 'nx-mcp-toast nx-mcp-toast-' + (type === 'error' ? 'error' : 'success');
1136 t.textContent = msg;
1137 document.body.appendChild(t);
1138 requestAnimationFrame(function(){ t.classList.add('nx-mcp-toast-in'); });
1139 setTimeout(function(){ t.classList.remove('nx-mcp-toast-in'); setTimeout(function(){ t.remove(); }, 320); }, 3600);
1140 };
1141 window.nxMcpCopy = function(btn, text){
1142 var done = function(){ var o = btn.textContent; btn.textContent = ''; setTimeout(function(){ btn.textContent = o; }, 1200); };
1143 if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, done); }
1144 else { var t=document.createElement('textarea'); t.value=text; document.body.appendChild(t); t.select(); try{document.execCommand('copy');}catch(e){} document.body.removeChild(t); done(); }
1145 };
1146 window.nxMcpReveal = function(btn){
1147 var code = btn.parentNode.querySelector('.nx-mcp-token'); if(!code) return;
1148 if (code.dataset.shown === '1'){ code.textContent = '••••••••••••'; code.dataset.shown='0'; btn.textContent='Show'; }
1149 else { code.textContent = code.dataset.token || ''; code.dataset.shown='1'; btn.textContent='Hide'; }
1150 };
1151 window.nxMcpAction = function(btn, action, opts){
1152 opts = opts || {};
1153 if (opts.confirm && !window.confirm(opts.confirm)) return;
1154 var old = btn.textContent; btn.disabled = true; btn.textContent = '';
1155 fetch(window.nxMcpData.urls[action], {
1156 method:'POST',
1157 headers:{'Content-Type':'application/json','X-WP-Nonce':window.nxMcpData.nonce},
1158 body: JSON.stringify(opts.body || {})
1159 }).then(function(r){ return r.json().catch(function(){ return {}; }); }).then(function(res){
1160 btn.disabled = false; btn.textContent = old;
1161 if (res && res.status === 'error'){ nxMcpToast('error', res.message || 'Something went wrong.'); return; }
1162 nxMcpToast('success', opts.success || (res && res.message) || 'Done.');
1163 if (opts.reload){ setTimeout(function(){ window.location.reload(); }, 900); }
1164 }).catch(function(){ btn.disabled = false; btn.textContent = old; nxMcpToast('error', 'Request failed.'); });
1165 };
1166 window.nxMcpRevoke = function(btn, type, clientId){
1167 nxMcpAction(btn, 'revoke', {
1168 confirm: 'Revoke this connection? The client will need to reconnect.',
1169 body: { type: type, client_id: clientId },
1170 reload: true,
1171 success: 'Connection revoked.'
1172 });
1173 };
1174 // Re-read the connected apps without a full page reload, so a newly
1175 // approved or detached client shows up immediately. Rows are built with
1176 // textContent because a client's name comes from dynamic registration.
1177 window.nxMcpRefreshApps = function(btn){
1178 var wrap = document.getElementById('nx-mcp-apps-wrap');
1179 if (!wrap) return;
1180 var old = btn ? btn.textContent : '';
1181 if (btn){ btn.disabled = true; btn.textContent = '\u2026'; }
1182 fetch(window.nxMcpData.urls.apps, {
1183 method: 'GET',
1184 credentials: 'same-origin',
1185 headers: { 'X-WP-Nonce': window.nxMcpData.nonce }
1186 }).then(function(r){ return r.json(); }).then(function(res){
1187 if (btn){ btn.disabled = false; btn.textContent = old; }
1188 if (!res || res.status !== 'success' || !Array.isArray(res.apps)){
1189 nxMcpToast('error', window.nxMcpData.i18n.refreshFailed); return;
1190 }
1191 // What was on screen before this refresh, so the toast can report
1192 // the actual delta rather than just restating a count.
1193 var prev = [];
1194 wrap.querySelectorAll('.nx-mcp-app').forEach(function(el){
1195 prev.push(el.getAttribute('data-nx-key') || '');
1196 });
1197 var next = res.apps.map(function(a){ return a.type + ':' + (a.client_id || ''); });
1198 var added = next.filter(function(k){ return prev.indexOf(k) === -1; }).length;
1199 var removed = prev.filter(function(k){ return next.indexOf(k) === -1; }).length;
1200 var i18n = window.nxMcpData.i18n;
1201 var toast;
1202 if (!added && !removed) {
1203 toast = next.length ? i18n.upToDate : i18n.noneStill;
1204 } else if (added && !removed) {
1205 toast = added === 1 ? i18n.addedOne : i18n.addedMany.replace('%d', added);
1206 } else if (removed && !added) {
1207 toast = removed === 1 ? i18n.removedOne : i18n.removedMany.replace('%d', removed);
1208 } else {
1209 toast = i18n.changed;
1210 }
1211
1212 while (wrap.firstChild) { wrap.removeChild(wrap.firstChild); }
1213 if (!res.apps.length){
1214 var p = document.createElement('p');
1215 p.className = 'nx-mcp-empty';
1216 p.textContent = i18n.empty;
1217 wrap.appendChild(p);
1218 nxMcpToast('success', toast);
1219 return;
1220 }
1221 var list = document.createElement('div');
1222 list.className = 'nx-mcp-apps';
1223 res.apps.forEach(function(app){
1224 var row = document.createElement('div'); row.className = 'nx-mcp-app';
1225 row.setAttribute('data-nx-key', app.type + ':' + (app.client_id || ''));
1226 var info = document.createElement('div'); info.className = 'nx-mcp-app-info';
1227 var name = document.createElement('strong'); name.textContent = app.name || '';
1228 var scope = document.createElement('span');
1229 scope.className = 'nx-mcp-scope ' + (app.read_only ? 'nx-mcp-scope-ro' : 'nx-mcp-scope-rw');
1230 scope.textContent = app.scope_label || '';
1231 info.appendChild(name); info.appendChild(scope);
1232 var rev = document.createElement('button');
1233 rev.type = 'button'; rev.className = 'nx-mcp-revoke';
1234 rev.textContent = window.nxMcpData.i18n.revoke;
1235 rev.addEventListener('click', function(){ nxMcpRevoke(rev, app.type, app.client_id || ''); });
1236 row.appendChild(info); row.appendChild(rev);
1237 list.appendChild(row);
1238 });
1239 wrap.appendChild(list);
1240 nxMcpToast('success', toast);
1241 }).catch(function(){
1242 if (btn){ btn.disabled = false; btn.textContent = old; }
1243 nxMcpToast('error', window.nxMcpData.i18n.refreshFailed);
1244 });
1245 };
1246 // The section heading ("Connected apps") is rendered by the settings form,
1247 // outside this message field, so the button starts inside the content box.
1248 // Move it onto that heading row once it exists — flex handles the exact
1249 // alignment, so no hard-coded offsets that break at the responsive padding
1250 // change. If this never runs the button simply stays in the box and works.
1251 window.nxMcpPlaceRefresh = function(){
1252 var btns = document.querySelectorAll('.nx-mcp-refresh-apps');
1253 if (!btns.length) return;
1254 var fresh = null, section = null, i;
1255 for (i = 0; i < btns.length; i++){
1256 var sec = btns[i].closest ? btns[i].closest('.wprf-control-section') : null;
1257 if (!sec) continue;
1258 var t = sec.querySelector('.wprf-section-title');
1259 if (!t) continue;
1260 section = sec;
1261 // A button still sitting in the content box is a freshly rendered one.
1262 if (!t.contains(btns[i])) { fresh = btns[i]; break; }
1263 }
1264 if (!section || !fresh) return;
1265 var title = section.querySelector('.wprf-section-title');
1266 if (!title) return;
1267 // Drop any previously moved button first, so a re-render cannot leave two.
1268 var stale = title.querySelectorAll('.nx-mcp-refresh-apps');
1269 for (i = 0; i < stale.length; i++){ stale[i].parentNode.removeChild(stale[i]); }
1270 title.appendChild(fresh);
1271 };
1272 if (window.MutationObserver){
1273 new MutationObserver(function(){ nxMcpPlaceRefresh(); })
1274 .observe(document.body, { childList: true, subtree: true });
1275 }
1276 document.addEventListener('DOMContentLoaded', function(){ nxMcpPlaceRefresh(); });
1277 nxMcpPlaceRefresh();
1278
1279 // Bound by delegation rather than an inline onclick, so the button keeps
1280 // working even if the panel markup is passed through a sanitiser.
1281 document.addEventListener('click', function(e){
1282 var btn = e.target && e.target.closest ? e.target.closest('.nx-mcp-refresh-apps') : null;
1283 if (!btn) return;
1284 e.preventDefault();
1285 nxMcpRefreshApps(btn);
1286 });
1287
1288 // Keep the status badge in sync with the enable toggle, live.
1289 document.addEventListener('change', function(e){
1290 if (!e.target || e.target.name !== 'enable_mcp') return;
1291 var badge = document.querySelector('.nx-mcp-badge');
1292 if (!badge) return;
1293 var on = !!e.target.checked;
1294 badge.textContent = on ? '<?php echo esc_js( __( 'Active', 'notificationx' ) ); ?>' : '<?php echo esc_js( __( 'Off', 'notificationx' ) ); ?>';
1295 badge.className = 'nx-mcp-badge nx-mcp-badge-' + (on ? 'active' : 'off');
1296 });
1297 </script>
1298 <?php
1299 }
1300
1301 /* --------------------------------------------------------------------- */
1302 /* Helpers */
1303 /* --------------------------------------------------------------------- */
1304
1305 /**
1306 * The request path relative to the WordPress home path, without query string.
1307 *
1308 * @return string
1309 */
1310 protected function request_path() {
1311 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- parsed below.
1312 $uri = esc_url_raw( $uri );
1313 $path = wp_parse_url( $uri, PHP_URL_PATH );
1314 if ( ! $path ) {
1315 return '';
1316 }
1317
1318 $home_path = wp_parse_url( home_url(), PHP_URL_PATH );
1319 if ( $home_path && 0 === strpos( $path, $home_path ) ) {
1320 $path = substr( $path, strlen( $home_path ) );
1321 }
1322
1323 return trim( $path, '/' );
1324 }
1325
1326 /**
1327 * Emit an array as a JSON document and stop.
1328 *
1329 * @param array $data Payload.
1330 * @return void
1331 */
1332 protected function emit_json( $data ) {
1333 nocache_headers();
1334 header( 'Content-Type: application/json; charset=utf-8' );
1335 header( 'Access-Control-Allow-Origin: *' );
1336 header( 'Cache-Control: public, max-age=3600' );
1337 echo wp_json_encode( $data );
1338 exit;
1339 }
1340
1341 /**
1342 * Emit a WP_REST_Response (status + headers + JSON body) and stop.
1343 *
1344 * @param \WP_REST_Response $response Response.
1345 * @return void
1346 */
1347 protected function emit_rest_response( $response ) {
1348 $status = $response->get_status();
1349 $headers = $response->get_headers();
1350 $data = $response->get_data();
1351
1352 if ( ! isset( $headers['Content-Type'] ) ) {
1353 header( 'Content-Type: application/json; charset=utf-8' );
1354 }
1355 foreach ( $headers as $key => $value ) {
1356 header( $key . ': ' . $value );
1357 }
1358 // Set the status LAST. Emitting an auth header such as WWW-Authenticate
1359 // after the status resets the code to 401 in this SAPI, so the status
1360 // must be asserted after every other header() call.
1361 status_header( $status );
1362 if ( function_exists( 'http_response_code' ) ) {
1363 http_response_code( $status );
1364 }
1365 if ( 202 === $status || null === $data ) {
1366 exit;
1367 }
1368 echo wp_json_encode( $data );
1369 exit;
1370 }
1371 }
1372