PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / 3.3.0
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar v3.3.0
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 3.3.0, at includes/MCP/Manager.php

1,179 lines 60.5 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 }
135
136 /**
137 * Revoke a single connected app (pairing token or one OAuth client).
138 *
139 * @param \WP_REST_Request $request Request.
140 * @return \WP_REST_Response
141 */
142 public function rest_revoke_app( $request ) {
143 $params = $request->get_json_params() ?: $request->get_body_params();
144 $type = isset( $params['type'] ) ? sanitize_text_field( $params['type'] ) : '';
145
146 if ( 'pairing' === $type ) {
147 Pairing::get_instance()->disconnect();
148 } elseif ( 'oauth' === $type && ! empty( $params['client_id'] ) ) {
149 OAuth::get_instance()->revoke_client( sanitize_text_field( $params['client_id'] ) );
150 } else {
151 return new \WP_REST_Response( array( 'status' => 'error', 'message' => __( 'Nothing to revoke.', 'notificationx' ) ), 400 );
152 }
153
154 return new \WP_REST_Response( array( 'status' => 'success' ), 200 );
155 }
156
157 /**
158 * Management permission: administrators only.
159 *
160 * @return bool
161 */
162 public function admin_permission() {
163 return current_user_can( 'manage_options' );
164 }
165
166 /**
167 * MCP transport handler (REST).
168 *
169 * @param \WP_REST_Request $request Request.
170 * @return \WP_REST_Response
171 */
172 public function rest_mcp( $request ) {
173 return Server::get_instance()->handle( $request );
174 }
175
176 /**
177 * OAuth dynamic client registration handler.
178 *
179 * @param \WP_REST_Request $request Request.
180 * @return \WP_REST_Response|\WP_Error
181 */
182 public function rest_oauth_register( $request ) {
183 if ( ! $this->is_enabled() ) {
184 return new \WP_REST_Response( array( 'error' => 'mcp_disabled' ), 403 );
185 }
186 $result = OAuth::get_instance()->register_client( $request->get_json_params() ?: array() );
187 if ( is_wp_error( $result ) ) {
188 return new \WP_REST_Response( array( 'error' => $result->get_error_code(), 'error_description' => $result->get_error_message() ), 400 );
189 }
190 return new \WP_REST_Response( $result, 201 );
191 }
192
193 /**
194 * OAuth token handler.
195 *
196 * @param \WP_REST_Request $request Request.
197 * @return \WP_REST_Response
198 */
199 public function rest_oauth_token( $request ) {
200 if ( ! $this->is_enabled() ) {
201 return new \WP_REST_Response( array( 'error' => 'mcp_disabled' ), 403 );
202 }
203 // Token requests are form-encoded per OAuth; fall back to JSON.
204 $params = $request->get_body_params();
205 if ( empty( $params ) ) {
206 $params = $request->get_json_params() ?: array();
207 }
208 $result = OAuth::get_instance()->handle_token_request( $params );
209 if ( is_wp_error( $result ) ) {
210 $resp = new \WP_REST_Response( array( 'error' => $result->get_error_code(), 'error_description' => $result->get_error_message() ), 400 );
211 } else {
212 $resp = new \WP_REST_Response( $result, 200 );
213 }
214 $resp->header( 'Cache-Control', 'no-store' );
215 $resp->header( 'Pragma', 'no-cache' );
216 return $resp;
217 }
218
219 /**
220 * Connection status for the admin UI.
221 *
222 * @return \WP_REST_Response
223 */
224 public function rest_connection() {
225 return new \WP_REST_Response( $this->connection_state(), 200 );
226 }
227
228 /**
229 * Enable a pairing connection.
230 *
231 * @return \WP_REST_Response
232 */
233 public function rest_connect() {
234 Pairing::get_instance()->connect();
235 return new \WP_REST_Response( array( 'status' => 'success' ) + $this->connection_state(), 200 );
236 }
237
238 /**
239 * Rotate the pairing token.
240 *
241 * @return \WP_REST_Response
242 */
243 public function rest_rotate() {
244 Pairing::get_instance()->rotate();
245 return new \WP_REST_Response( array( 'status' => 'success' ) + $this->connection_state(), 200 );
246 }
247
248 /**
249 * Disconnect: drop the pairing token and revoke all OAuth grants.
250 *
251 * @return \WP_REST_Response
252 */
253 public function rest_disconnect() {
254 Pairing::get_instance()->disconnect();
255 OAuth::get_instance()->revoke_all();
256 return new \WP_REST_Response( array( 'status' => 'success' ), 200 );
257 }
258
259 /**
260 * Run the loopback self-test.
261 *
262 * @return \WP_REST_Response
263 */
264 public function rest_self_test() {
265 $result = SelfTest::get_instance()->run();
266 return new \WP_REST_Response( array( 'status' => $result['ok'] ? 'success' : 'error', 'message' => $result['message'] ) + $result, 200 );
267 }
268
269 /**
270 * Summarise the connection for the admin UI.
271 *
272 * @return array
273 */
274 protected function connection_state() {
275 $pairing = Pairing::get_instance();
276 return array(
277 'enabled' => $this->is_enabled(),
278 'connected' => $pairing->is_connected(),
279 'connector_url' => $this->connector_url(),
280 'token' => $pairing->site_token(),
281 );
282 }
283
284 /* --------------------------------------------------------------------- */
285 /* Front-end requests: pretty endpoint, discovery, authorize page */
286 /* --------------------------------------------------------------------- */
287
288 /**
289 * Intercept the MCP pretty endpoint, OAuth discovery docs and the
290 * authorize page from the front controller. Path-based so it works under
291 * any permalink structure without rewrite flushes.
292 *
293 * @param \WP $wp WordPress environment.
294 * @return void
295 */
296 public function handle_front_requests( $wp ) {
297 $path = $this->request_path();
298 if ( '' === $path ) {
299 return;
300 }
301
302 // OAuth discovery (also accept the path-suffixed RFC form).
303 if ( 0 === strpos( $path, '.well-known/oauth-authorization-server' ) ) {
304 $this->emit_json( OAuth::get_instance()->authorization_server_metadata() );
305 }
306 if ( 0 === strpos( $path, '.well-known/oauth-protected-resource' ) ) {
307 $this->emit_json( OAuth::get_instance()->protected_resource_metadata() );
308 }
309
310 // Pretty MCP endpoint.
311 if ( 'notificationx/mcp' === $path ) {
312 $this->handle_pretty_mcp();
313 }
314
315 // OAuth authorize consent page.
316 if ( 'notificationx/authorize' === $path ) {
317 $this->handle_authorize();
318 }
319 }
320
321 /**
322 * Handle the pretty MCP endpoint by delegating to the JSON-RPC server.
323 *
324 * @return void
325 */
326 protected function handle_pretty_mcp() {
327 // Only POST carries a JSON-RPC body; a GET is treated as a probe so
328 // clients discovering the endpoint still get a challenge.
329 $request = new \WP_REST_Request( 'POST', '/notificationx/v1/mcp' );
330 $auth = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- header validated downstream.
331 if ( $auth ) {
332 $request->set_header( 'authorization', $auth );
333 }
334 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- raw JSON-RPC body, parsed/validated by the server.
335 $request->set_body( file_get_contents( 'php://input' ) );
336
337 $response = Server::get_instance()->handle( $request );
338 $this->emit_rest_response( $response );
339 }
340
341 /**
342 * Render / process the OAuth authorize consent page.
343 *
344 * @return void
345 */
346 protected function handle_authorize() {
347 if ( ! $this->is_enabled() ) {
348 status_header( 404 );
349 exit;
350 }
351
352 // Require a logged-in administrator; bounce through wp-login if needed.
353 if ( ! is_user_logged_in() ) {
354 $current = ( is_ssl() ? 'https://' : 'http://' ) . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) ) . sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
355 wp_safe_redirect( wp_login_url( $current ) );
356 exit;
357 }
358 if ( ! current_user_can( 'manage_options' ) ) {
359 wp_die( esc_html__( 'You do not have permission to authorize an MCP connection.', 'notificationx' ) );
360 }
361
362 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- these are OAuth request params echoed back into a nonce-protected consent form; no state change on GET.
363 $params = wp_unslash( $_GET );
364 $request = OAuth::get_instance()->validate_authorize_request( $params );
365 if ( is_wp_error( $request ) ) {
366 wp_die( esc_html( $request->get_error_message() ) );
367 }
368
369 $is_post = ( 'POST' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) ) );
370
371 // Deny on POST (nonce-checked): bounce back to the client with the
372 // standard OAuth error so it can end the flow cleanly instead of the
373 // user landing on a dead browser tab.
374 if ( $is_post && isset( $_POST['nx_mcp_deny'] ) ) {
375 check_admin_referer( 'nx_mcp_authorize' );
376 $redirect = add_query_arg(
377 array(
378 'error' => 'access_denied',
379 'error_description' => rawurlencode( 'The user denied the authorization request.' ),
380 'state' => rawurlencode( $request['state'] ),
381 ),
382 $request['redirect_uri']
383 );
384 wp_redirect( $redirect ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- redirect_uri is validated against the registered client allow-list.
385 exit;
386 }
387
388 // Approve on POST (nonce-checked).
389 if ( $is_post && isset( $_POST['nx_mcp_authorize'] ) ) {
390 check_admin_referer( 'nx_mcp_authorize' );
391 $code = OAuth::get_instance()->issue_code( $request, get_current_user_id() );
392 $redirect = add_query_arg(
393 array(
394 'code' => rawurlencode( $code ),
395 'state' => rawurlencode( $request['state'] ),
396 ),
397 $request['redirect_uri']
398 );
399 wp_redirect( $redirect ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- redirect_uri is validated against the registered client allow-list.
400 exit;
401 }
402
403 $this->render_authorize_page( $request );
404 }
405
406 /**
407 * Output the consent form.
408 *
409 * @param array $request Validated authorize request.
410 * @return void
411 */
412 protected function render_authorize_page( $request ) {
413 $store = get_option( OAuth::OPTION, array() );
414 $client = isset( $store['clients'][ $request['client_id'] ] ) ? $store['clients'][ $request['client_id'] ] : array();
415 $name = ! empty( $client['client_name'] ) ? $client['client_name'] : $request['client_id'];
416 $scope = $request['scope'];
417
418 // What the granted scope actually permits, in plain language.
419 $read_only = OAuth::get_instance()->scope_is_read_only( $scope );
420
421 // The two ends of the connection: the client app and this site.
422 $client_host = (string) wp_parse_url( $request['redirect_uri'], PHP_URL_HOST );
423 $site_name = get_bloginfo( 'name' );
424 $site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
425
426 // Who is about to approve — everything the connection does is recorded
427 // as this user.
428 $user = wp_get_current_user();
429 $who_name = $user->display_name ? $user->display_name : $user->user_login;
430 $roles = (array) $user->roles;
431 $role_key = $roles ? (string) reset( $roles ) : '';
432 $role_lbl = '';
433 if ( $role_key ) {
434 $wp_roles = wp_roles();
435 if ( isset( $wp_roles->roles[ $role_key ]['name'] ) ) {
436 $role_lbl = translate_user_role( $wp_roles->roles[ $role_key ]['name'] );
437 }
438 }
439 $substr = function_exists( 'mb_substr' ) ? 'mb_substr' : 'substr';
440 $who_initial = strtoupper( $substr( $who_name, 0, 1 ) );
441 $client_initial = strtoupper( $substr( $name, 0, 1 ) );
442 // Show the connecting app's own mark when we recognise it; otherwise the initial.
443 $client_is_claude = ( false !== stripos( $name, 'claude' ) );
444
445 // The exact tools this grant unlocks, straight from the ability
446 // registry so the list can never drift from what the server exposes.
447 Registrar::get_instance()->boot();
448 $granted = array();
449 foreach ( Registrar::get_instance()->get_all() as $ability ) {
450 if ( $read_only && $ability->is_write() ) {
451 continue;
452 }
453 $granted[] = $ability;
454 }
455
456 $cap_label = $read_only ? __( 'Read only', 'notificationx' ) : __( 'Read & write', 'notificationx' );
457 $cap_text = $read_only
458 ? __( 'It can read your notifications, entries and analytics. It cannot create, change or delete anything.', 'notificationx' )
459 : __( 'It acts as you: anything it creates, edits or deletes is recorded under your account.', 'notificationx' );
460
461 // NotificationX brand mark (assets/admin/images/nx-icon.svg), inlined so
462 // the consent page never depends on a second asset request.
463 $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>';
464
465 nocache_headers();
466 header( 'Content-Type: text/html; charset=utf-8' );
467 ?>
468 <!doctype html>
469 <html <?php language_attributes(); ?>>
470 <head>
471 <meta charset="<?php bloginfo( 'charset' ); ?>">
472 <meta name="viewport" content="width=device-width, initial-scale=1">
473 <meta name="robots" content="noindex,nofollow">
474 <title><?php esc_html_e( 'Authorize MCP connection', 'notificationx' ); ?></title>
475 <style>
476 :root{--nx:#6a4bff;--nx-dark:#5614d5;--ink:#1a1a2e;--muted:#5b6072;--line:#e7e7ef}
477 *{box-sizing:border-box}
478 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%)}
479 .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)}
480 .apps{display:flex;align-items:flex-start;justify-content:center;gap:8px;margin:4px 0 22px}
481 .app{width:132px;text-align:center}
482 .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)}
483 .tile.client{background:#eef0f6;color:#3a4056;font-size:26px;font-weight:700}
484 .tile.client.has-mark{background:#fdf1ec}
485 .tile.client svg{width:38px;height:38px;display:block}
486 .tile.nx{background:#fff;border:1px solid var(--line)}
487 .tile.nx svg{width:42px;height:42px;display:block}
488 .app-name{font-size:14px;font-weight:600;line-height:1.3}
489 .app-host{font-size:12px;color:var(--muted);word-break:break-word;margin-top:2px}
490 .conn{flex:0 0 auto;align-self:center;margin-top:8px;display:flex;align-items:center;gap:6px;color:#b7b9c9}
491 .conn i{display:block;width:14px;height:0;border-top:2px dotted currentColor}
492 .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}
493 h1{font-size:19px;line-height:1.45;margin:0 0 20px;text-align:center;font-weight:600}
494 h1 strong{font-weight:700}
495 .cap{border-radius:14px;padding:16px 16px 14px;border:1px solid #e4defb;background:#f6f3ff}
496 .cap.ro{border-color:#dfe6f2;background:#f2f6fc}
497 .pill{display:inline-block;font-size:12px;font-weight:700;padding:5px 12px;border-radius:999px;background:var(--nx);color:#fff}
498 .cap.ro .pill{background:#3f6fd6}
499 .cap p{margin:11px 0 0;font-size:13px;line-height:1.55;color:#403c5c}
500 .who{display:flex;align-items:center;gap:10px;margin:16px 2px 0;font-size:13px;color:var(--muted)}
501 .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}
502 .who b{color:var(--ink)}
503 details{margin-top:14px;border:1px solid var(--line);border-radius:12px;overflow:hidden}
504 summary{list-style:none;cursor:pointer;padding:13px 15px;font-size:14px;font-weight:600;display:flex;align-items:center;justify-content:space-between}
505 summary::-webkit-details-marker{display:none}
506 summary .chev{transition:transform .15s ease;color:var(--muted)}
507 details[open] summary .chev{transform:rotate(180deg)}
508 .abilities{margin:0;padding:2px 6px 8px;list-style:none}
509 .abilities li{padding:9px 9px;border-top:1px solid var(--line)}
510 .abilities .a-name{font-size:13px;font-weight:600}
511 .abilities .a-desc{font-size:12px;color:var(--muted);margin-top:2px;line-height:1.45}
512 .secured{display:flex;align-items:flex-start;gap:8px;margin:16px 2px 0;font-size:12px;color:var(--muted);line-height:1.5}
513 .secured svg{flex:0 0 auto;margin-top:1px}
514 .actions{display:flex;gap:12px;margin-top:22px}
515 button{flex:1;padding:13px;border-radius:11px;font-size:14px;font-weight:700;cursor:pointer;border:1px solid transparent}
516 .approve{background:var(--nx);color:#fff}
517 .approve:hover{background:var(--nx-dark)}
518 .deny{background:#fff;color:var(--ink);border-color:var(--line)}
519 .deny:hover{background:#f6f6fa}
520 </style>
521 </head>
522 <body>
523 <div class="card">
524 <div class="apps">
525 <div class="app">
526 <div class="tile client<?php echo $client_is_claude ? ' has-mark' : ''; ?>">
527 <?php if ( $client_is_claude ) : ?>
528 <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>
529 <?php else : ?>
530 <?php echo esc_html( $client_initial ); ?>
531 <?php endif; ?>
532 </div>
533 <div class="app-name"><?php echo esc_html( $name ); ?></div>
534 <?php if ( $client_host ) : ?><div class="app-host"><?php echo esc_html( $client_host ); ?></div><?php endif; ?>
535 </div>
536 <div class="conn" aria-hidden="true"><i></i><span class="dot">&rarr;</span><i></i></div>
537 <div class="app">
538 <div class="tile nx"><?php echo $nx_mark; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static inline brand SVG, no dynamic data. ?></div>
539 <div class="app-name">NotificationX</div>
540 <?php if ( $site_host ) : ?><div class="app-host"><?php echo esc_html( $site_host ); ?></div><?php endif; ?>
541 </div>
542 </div>
543
544 <h1>
545 <?php
546 printf(
547 /* translators: %1$s: client app name, %2$s: site name. */
548 esc_html__( '%1$s wants to work with your notifications on %2$s.', 'notificationx' ),
549 '<strong>' . esc_html( $name ) . '</strong>',
550 '<strong>' . esc_html( $site_name ? $site_name : $site_host ) . '</strong>'
551 );
552 ?>
553 </h1>
554
555 <div class="cap <?php echo $read_only ? 'ro' : ''; ?>">
556 <span class="pill"><?php echo esc_html( $cap_label ); ?></span>
557 <p><?php echo esc_html( $cap_text ); ?></p>
558 </div>
559
560 <div class="who">
561 <span class="avatar"><?php echo esc_html( $who_initial ); ?></span>
562 <span>
563 <?php
564 printf(
565 /* translators: %1$s: user display name, %2$s: user role. */
566 esc_html__( 'Signed in as %1$s%2$s', 'notificationx' ),
567 '<b>' . esc_html( $who_name ) . '</b>',
568 $role_lbl ? ' &middot; ' . esc_html( $role_lbl ) : ''
569 );
570 ?>
571 </span>
572 </div>
573
574 <?php if ( $granted ) : ?>
575 <details>
576 <summary>
577 <span>
578 <?php
579 printf(
580 /* translators: %1$s: client app name, %2$d: number of tools. */
581 esc_html__( 'What %1$s will be able to do (%2$d)', 'notificationx' ),
582 esc_html( $name ),
583 count( $granted )
584 );
585 ?>
586 </span>
587 <span class="chev">&#9662;</span>
588 </summary>
589 <ul class="abilities">
590 <?php foreach ( $granted as $ability ) : ?>
591 <li>
592 <div class="a-name"><?php echo esc_html( $ability->get_label() ); ?></div>
593 <div class="a-desc"><?php echo esc_html( $ability->get_description() ); ?></div>
594 </li>
595 <?php endforeach; ?>
596 </ul>
597 </details>
598 <?php endif; ?>
599
600 <div class="secured">
601 <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>
602 <span>
603 <?php esc_html_e( 'Secured with OAuth. You can revoke this app at any time under NotificationX → MCP.', 'notificationx' ); ?>
604 </span>
605 </div>
606
607 <form method="post">
608 <?php wp_nonce_field( 'nx_mcp_authorize' ); ?>
609 <div class="actions">
610 <button type="submit" class="deny" name="nx_mcp_deny" value="1"><?php esc_html_e( 'Deny', 'notificationx' ); ?></button>
611 <button type="submit" class="approve" name="nx_mcp_authorize" value="1"><?php esc_html_e( 'Approve', 'notificationx' ); ?></button>
612 </div>
613 </form>
614 </div>
615 </body>
616 </html>
617 <?php
618 exit;
619 }
620
621 /* --------------------------------------------------------------------- */
622 /* Settings tab (NotificationX admin flow) */
623 /* --------------------------------------------------------------------- */
624
625 /**
626 * Add the "MCP" tab to NotificationX settings.
627 *
628 * @param array $tabs Existing tabs.
629 * @return array
630 */
631 public function register_settings_tab( $tabs ) {
632 // If MCP is on, make sure a pairing token exists so the UI has one to show.
633 if ( $this->is_enabled() && ! Pairing::get_instance()->is_connected() ) {
634 Pairing::get_instance()->connect();
635 }
636
637 $tabs['tab-mcp'] = array(
638 'id' => 'tab-mcp',
639 'label' => __( 'MCP', 'notificationx' ),
640 'priority' => 45,
641 'fields' => $this->settings_fields(),
642 );
643
644 return $tabs;
645 }
646
647 /**
648 * Build the MCP settings field schema. The rich panels are server-rendered
649 * HTML delivered through quickbuilder `message` fields (html => true); the
650 * action buttons use quickbuilder `button` fields for the ajax + toast.
651 *
652 * @return array
653 */
654 protected function settings_fields() {
655 $enabled_rule = Rules::is( 'enable_mcp', true );
656
657 $fields = array(
658 'mcp_main_section' => array(
659 'name' => 'mcp_main_section',
660 'type' => 'section',
661 'label' => __( 'MCP Server', 'notificationx' ),
662 'fields' => array(
663 'mcp_hero' => array(
664 'name' => 'mcp_hero',
665 'type' => 'message',
666 'html' => true,
667 'message' => $this->hero_html(),
668 ),
669 'enable_mcp' => array(
670 'name' => 'enable_mcp',
671 'type' => 'toggle',
672 'default' => false,
673 'label' => __( 'Enable MCP access', 'notificationx' ),
674 'help' => __( 'When enabled and saved, approved AI assistants can connect to this site to manage notifications and read analytics.', 'notificationx' ),
675 ),
676 ),
677 ),
678
679 'mcp_connection_section' => array(
680 'name' => 'mcp_connection_section',
681 'type' => 'section',
682 'label' => __( 'Connection', 'notificationx' ),
683 'rules' => $enabled_rule,
684 'fields' => array(
685 'mcp_connection_html' => array(
686 'name' => 'mcp_connection_html',
687 'type' => 'message',
688 'html' => true,
689 'message' => $this->connection_html(),
690 ),
691 ),
692 ),
693
694 'mcp_clients_section' => array(
695 'name' => 'mcp_clients_section',
696 'type' => 'section',
697 'label' => __( 'Connect a client', 'notificationx' ),
698 'rules' => $enabled_rule,
699 'fields' => array(
700 'mcp_clients_html' => array(
701 'name' => 'mcp_clients_html',
702 'type' => 'message',
703 'html' => true,
704 'message' => $this->clients_html(),
705 ),
706 ),
707 ),
708
709 'mcp_apps_section' => array(
710 'name' => 'mcp_apps_section',
711 'type' => 'section',
712 'label' => __( 'Connected apps', 'notificationx' ),
713 'rules' => $enabled_rule,
714 'fields' => array(
715 'mcp_apps_html' => array(
716 'name' => 'mcp_apps_html',
717 'type' => 'message',
718 'html' => true,
719 'message' => $this->connected_apps_html(),
720 ),
721 ),
722 ),
723
724 'mcp_health_section' => array(
725 'name' => 'mcp_health_section',
726 'type' => 'section',
727 'label' => __( 'Connection health', 'notificationx' ),
728 'rules' => $enabled_rule,
729 'fields' => array(
730 'mcp_health_html' => array(
731 'name' => 'mcp_health_html',
732 'type' => 'message',
733 'html' => true,
734 'message' => $this->health_html(),
735 ),
736 ),
737 ),
738 );
739
740 return $fields;
741 }
742
743 /**
744 * Current status: off | setup | active.
745 *
746 * @return array [ state, label ]
747 */
748 protected function status() {
749 if ( ! $this->is_enabled() ) {
750 return array( 'off', __( 'Off', 'notificationx' ) );
751 }
752 if ( Pairing::get_instance()->is_connected() ) {
753 return array( 'active', __( 'Active', 'notificationx' ) );
754 }
755 return array( 'setup', __( 'Setup needed', 'notificationx' ) );
756 }
757
758 /**
759 * Hero header with the status badge.
760 *
761 * @return string
762 */
763 protected function hero_html() {
764 list( $state, $label ) = $this->status();
765 ob_start();
766 ?>
767 <div class="nx-mcp-hero">
768 <div class="nx-mcp-hero-icon">&#128268;</div>
769 <div class="nx-mcp-hero-body">
770 <h3 class="nx-mcp-hero-title">
771 <?php esc_html_e( 'MCP Server', 'notificationx' ); ?>
772 <span class="nx-mcp-badge nx-mcp-badge-<?php echo esc_attr( $state ); ?>"><?php echo esc_html( $label ); ?></span>
773 </h3>
774 <p class="nx-mcp-hero-text">
775 <?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' ); ?>
776 </p>
777 <a class="nx-mcp-learn" href="<?php echo esc_url( 'https://notificationx.com/docs/mcp-in-notificationx' ); ?>" target="_blank" rel="noopener noreferrer">
778 <span class="nx-mcp-learn-text"><?php esc_html_e( 'Learn how it works', 'notificationx' ); ?></span>
779 <span class="nx-mcp-learn-arrow" aria-hidden="true">&rarr;</span>
780 </a>
781 </div>
782 </div>
783 <?php
784 return ob_get_clean();
785 }
786
787 /**
788 * Connector URL + token cards with copy/reveal controls.
789 *
790 * @return string
791 */
792 protected function connection_html() {
793 $url = $this->connector_url();
794 $token = Pairing::get_instance()->site_token();
795 ob_start();
796 ?>
797 <div class="nx-mcp-grid">
798 <div class="nx-mcp-card">
799 <span class="nx-mcp-card-label"><?php esc_html_e( 'Connector URL', 'notificationx' ); ?></span>
800 <div class="nx-mcp-copyrow">
801 <code class="nx-mcp-value"><?php echo esc_html( $url ); ?></code>
802 <button type="button" class="nx-mcp-copy" onclick="nxMcpCopy(this,'<?php echo esc_js( $url ); ?>')"><?php esc_html_e( 'Copy', 'notificationx' ); ?></button>
803 </div>
804 <p class="nx-mcp-hint"><?php esc_html_e( 'Add this URL as a custom connector in your AI client.', 'notificationx' ); ?></p>
805 </div>
806 <div class="nx-mcp-card">
807 <span class="nx-mcp-card-label"><?php esc_html_e( 'Connection token', 'notificationx' ); ?></span>
808 <div class="nx-mcp-copyrow">
809 <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>
810 <button type="button" class="nx-mcp-copy" onclick="nxMcpReveal(this)"><?php esc_html_e( 'Show', 'notificationx' ); ?></button>
811 <button type="button" class="nx-mcp-copy" onclick="nxMcpCopy(this,'<?php echo esc_js( $token ); ?>')"><?php esc_html_e( 'Copy', 'notificationx' ); ?></button>
812 </div>
813 <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>
814 </div>
815 </div>
816 <div class="nx-mcp-actions">
817 <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>
818 <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>
819 </div>
820 <?php
821 return ob_get_clean();
822 }
823
824 /**
825 * Per-client setup cards.
826 *
827 * @return string
828 */
829 protected function clients_html() {
830 $url = esc_html( $this->connector_url() );
831 ob_start();
832 ?>
833 <div class="nx-mcp-clients">
834 <div class="nx-mcp-client">
835 <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>
836 <ol class="nx-mcp-steps">
837 <li><?php esc_html_e( 'In Claude, add a custom connector.', 'notificationx' ); ?></li>
838 <li><?php esc_html_e( 'Paste the Connector URL above.', 'notificationx' ); ?></li>
839 <li><?php esc_html_e( 'Approve the connection when prompted — you sign in here, no token needed.', 'notificationx' ); ?></li>
840 </ol>
841 </div>
842 <div class="nx-mcp-client">
843 <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>
844 <ol class="nx-mcp-steps">
845 <li><?php esc_html_e( 'Settings → Connectors → Add a custom connector.', 'notificationx' ); ?></li>
846 <li><?php /* translators: %s: connector URL */ printf( esc_html__( 'Use the URL %s.', 'notificationx' ), '<code>' . $url . '</code>' ); ?></li>
847 <li><?php esc_html_e( 'Provide the connection token as a Bearer credential.', 'notificationx' ); ?></li>
848 </ol>
849 </div>
850 <div class="nx-mcp-client">
851 <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>
852 <ol class="nx-mcp-steps">
853 <li><?php esc_html_e( 'Add an MCP server with the Connector URL above.', 'notificationx' ); ?></li>
854 <li><?php esc_html_e( 'Set the Authorization header to: Bearer <token>.', 'notificationx' ); ?></li>
855 <li><?php esc_html_e( 'Confirm the install when the client asks.', 'notificationx' ); ?></li>
856 </ol>
857 </div>
858 </div>
859 <?php
860 return ob_get_clean();
861 }
862
863 /**
864 * The list of currently connected AI apps (pairing token + OAuth clients).
865 *
866 * @return string
867 */
868 protected function connected_apps_html() {
869 $apps = array();
870
871 // Only list the token connection once a client has actually used it —
872 // the token existing on its own is not a "connected app".
873 $pairing = Pairing::get_instance();
874 $pstate = $pairing->state();
875 if ( $pairing->is_connected() && ! empty( $pstate['last_used'] ) ) {
876 $apps[] = array(
877 'type' => 'pairing',
878 'client_id' => '',
879 'name' => __( 'Token connection (ChatGPT / Cursor / manual)', 'notificationx' ),
880 'read_only' => $pairing->is_read_only(),
881 );
882 }
883 foreach ( OAuth::get_instance()->list_active_clients() as $client ) {
884 $apps[] = array(
885 'type' => 'oauth',
886 'client_id' => $client['client_id'],
887 'name' => $client['name'],
888 'read_only' => ! empty( $client['read_only'] ),
889 );
890 }
891
892 ob_start();
893 if ( empty( $apps ) ) {
894 echo '<p class="nx-mcp-empty">' . esc_html__( 'No AI clients are connected yet.', 'notificationx' ) . '</p>';
895 } else {
896 echo '<div class="nx-mcp-apps">';
897 foreach ( $apps as $app ) {
898 $scope_class = $app['read_only'] ? 'nx-mcp-scope-ro' : 'nx-mcp-scope-rw';
899 $scope_label = $app['read_only'] ? __( 'Read-only', 'notificationx' ) : __( 'Read & write', 'notificationx' );
900 ?>
901 <div class="nx-mcp-app">
902 <div class="nx-mcp-app-info">
903 <strong><?php echo esc_html( $app['name'] ); ?></strong>
904 <span class="nx-mcp-scope <?php echo esc_attr( $scope_class ); ?>"><?php echo esc_html( $scope_label ); ?></span>
905 </div>
906 <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>
907 </div>
908 <?php
909 }
910 echo '</div>';
911 }
912 return ob_get_clean();
913 }
914
915 /**
916 * Connection health panel.
917 *
918 * @return string
919 */
920 protected function health_html() {
921 $secure = is_ssl();
922 ob_start();
923 ?>
924 <div class="nx-mcp-health">
925 <div class="nx-mcp-health-row">
926 <span class="nx-mcp-dot <?php echo $secure ? 'nx-mcp-dot-good' : 'nx-mcp-dot-warn'; ?>"></span>
927 <?php if ( $secure ) : ?>
928 <?php esc_html_e( 'Secure connection (HTTPS) is on.', 'notificationx' ); ?>
929 <?php else : ?>
930 <?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' ); ?>
931 <?php endif; ?>
932 </div>
933 <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>
934 <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>
935 <p class="nx-mcp-hint"><?php esc_html_e( 'Use “Test connection” above to verify the server end-to-end.', 'notificationx' ); ?></p>
936 </div>
937 <div class="nx-mcp-danger">
938 <div class="nx-mcp-danger-text">
939 <strong><?php esc_html_e( 'Disconnect all', 'notificationx' ); ?></strong>
940 <span><?php esc_html_e( 'Revoke every connection and OAuth grant. All clients will need to reconnect.', 'notificationx' ); ?></span>
941 </div>
942 <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>
943 </div>
944 <?php
945 return ob_get_clean();
946 }
947
948 /**
949 * Print the MCP panel CSS + JS on the NotificationX settings page.
950 * (The onclick handlers in the rendered HTML reference these globals.)
951 *
952 * @return void
953 */
954 public function print_panel_assets() {
955 // The NotificationX admin is a single-page app (BrowserRouter): moving
956 // between its screens — including into Settings → MCP — is client-side, so
957 // admin_print_footer_scripts fires only on the first full page load,
958 // whatever NX screen that happened to be. Print the panel CSS/JS on every
959 // NotificationX admin page (slug prefixed "nx-"), not just nx-settings, so
960 // the styles/handlers are already on the document when the MCP tab renders
961 // after a client-side navigation. Otherwise the panel shows unstyled until
962 // a manual reload.
963 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check.
964 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
965 if ( ! is_admin() || 0 !== strpos( $page, 'nx-' ) ) {
966 return;
967 }
968 $nonce = wp_create_nonce( 'wp_rest' );
969 $urls = array(
970 'test' => esc_url_raw( rest_url( 'notificationx/v1/mcp/self-test' ) ),
971 'rotate' => esc_url_raw( rest_url( 'notificationx/v1/mcp/rotate' ) ),
972 'disconnect' => esc_url_raw( rest_url( 'notificationx/v1/mcp/disconnect' ) ),
973 'revoke' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps/revoke' ) ),
974 );
975 ?>
976 <style id="nx-mcp-panel-css">
977 .nx-mcp-hero{display:flex;gap:14px;align-items:flex-start}
978 .nx-mcp-hero-icon{font-size:26px;line-height:1}
979 .nx-mcp-hero-title{margin:0 0 6px;font-size:18px;display:flex;align-items:center;gap:10px}
980 .nx-mcp-hero-text{margin:0;color:#50575e;max-width:640px}
981 .nx-mcp-learn{display:inline-flex;align-items:center;gap:5px;margin-top:10px;color:#6a4bff;font-size:13px;font-weight:600}
982 /* The message-field CSS (#notificationx .wprf-message p a) underlines the
983 whole anchor at rest, which draws a line under the arrow too. Override
984 it in every state (!important beats that #id rule) and underline only
985 the text span on hover. */
986 .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}
987 .nx-mcp-learn .nx-mcp-learn-text{text-decoration:none}
988 .nx-mcp-learn:hover .nx-mcp-learn-text{text-decoration:underline}
989 .nx-mcp-learn-arrow{display:inline-block;transition:transform .2s}
990 .nx-mcp-learn:hover .nx-mcp-learn-arrow{transform:translateX(3px)}
991 .nx-mcp-badge{font-size:11px;font-weight:600;padding:2px 10px;border-radius:999px;text-transform:uppercase;letter-spacing:.02em}
992 .nx-mcp-badge-off{background:#e2e4e7;color:#50575e}
993 .nx-mcp-badge-active{background:#e5f6ea;color:#1a7f37}
994 .nx-mcp-badge-setup{background:#fcf3e3;color:#996800}
995 /* Enable toggle: keep label + switch on one row (no fixed 200px label
996 column gap) and let the help text span full-width, left-aligned. */
997 .wprf-name-enable_mcp{display:flex;flex-wrap:wrap;align-items:center}
998 .wprf-name-enable_mcp .wprf-control-label{width:auto!important;flex:0 0 auto!important;margin:0 12px 0 0!important}
999 .wprf-name-enable_mcp .wprf-control-field{display:contents}
1000 .wprf-name-enable_mcp .wprf-toggle-wrap{order:2}
1001 .wprf-name-enable_mcp .wprf-help{order:3;flex-basis:100%;width:100%;margin:8px 0 0!important}
1002 .nx-mcp-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
1003 @media(max-width:782px){.nx-mcp-grid{grid-template-columns:1fr}}
1004 .nx-mcp-card{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff}
1005 .nx-mcp-card-label{display:block;font-weight:600;font-size:12px;color:#50575e;text-transform:uppercase;letter-spacing:.03em;margin-bottom:8px}
1006 .nx-mcp-copyrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
1007 .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}
1008 .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}
1009 .nx-mcp-copy:hover{background:#eef0f1}
1010 .nx-mcp-hint{margin:8px 0 0;color:#787c82;font-size:12px}
1011 .nx-mcp-clients{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
1012 @media(max-width:960px){.nx-mcp-clients{grid-template-columns:1fr}}
1013 .nx-mcp-client{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff}
1014 .nx-mcp-client-name{font-weight:600;display:flex;align-items:center;gap:8px;margin-bottom:8px}
1015 .nx-mcp-tag{font-size:10px;font-weight:600;background:#f0eefe;color:#6a4bff;padding:2px 8px;border-radius:999px;text-transform:uppercase}
1016 .nx-mcp-steps{margin:0;padding-left:18px;color:#50575e;font-size:13px;line-height:1.7}
1017 .nx-mcp-apps{display:flex;flex-direction:column;gap:10px}
1018 .nx-mcp-app{display:flex;justify-content:space-between;align-items:center;border:1px solid #e0e0e0;border-radius:8px;padding:10px 14px;background:#fff}
1019 .nx-mcp-app-info{display:flex;align-items:center;gap:10px}
1020 .nx-mcp-scope{font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px}
1021 .nx-mcp-scope-ro{background:#eef0f1;color:#50575e}
1022 .nx-mcp-scope-rw{background:#e5f6ea;color:#1a7f37}
1023 .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}
1024 .nx-mcp-revoke:hover{background:#d63638;color:#fff}
1025 .nx-mcp-empty{color:#787c82;font-style:italic}
1026 .nx-mcp-health{display:flex;flex-direction:column;gap:8px}
1027 .nx-mcp-health-row{display:flex;align-items:center;gap:8px;color:#2c3338;font-size:13px}
1028 .nx-mcp-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
1029 .nx-mcp-dot-good{background:#1a7f37}
1030 .nx-mcp-dot-warn{background:#dba617}
1031 /* Client icons are <img> tags pointing at real SVG files: the card HTML is
1032 kses-filtered, which strips <svg> and rejects data: URIs in src/style. */
1033 .nx-mcp-client-ic{width:20px;height:20px;flex:none;display:inline-block;vertical-align:middle}
1034 .nx-mcp-actions{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap}
1035 .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}
1036 .nx-mcp-btn[disabled]{opacity:.6;cursor:default}
1037 .nx-mcp-btn-secondary{background:#6a4bff;color:#fff}
1038 .nx-mcp-btn-secondary:hover{background:#583fd6}
1039 .nx-mcp-btn-ghost{background:#fff;color:#2c3338;border-color:#c3c4c7}
1040 .nx-mcp-btn-ghost:hover{background:#f6f7f7}
1041 .nx-mcp-btn-danger{background:#d63638;color:#fff;border-color:#d63638}
1042 .nx-mcp-btn-danger:hover{background:#b32d2e}
1043 .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}
1044 .nx-mcp-danger-text{display:flex;flex-direction:column;gap:2px}
1045 .nx-mcp-danger-text strong{color:#8a1f21}
1046 .nx-mcp-danger-text span{color:#a15b5b;font-size:12px}
1047 .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}
1048 .nx-mcp-toast-in{opacity:1;transform:translateY(0)}
1049 .nx-mcp-toast-success{background:#1a7f37}
1050 .nx-mcp-toast-error{background:#d63638}
1051 </style>
1052 <script id="nx-mcp-panel-js">
1053 window.nxMcpData = { urls: <?php echo wp_json_encode( $urls ); ?>, nonce: <?php echo wp_json_encode( $nonce ); ?> };
1054 window.nxMcpToast = function(type, msg){
1055 var t = document.createElement('div');
1056 t.className = 'nx-mcp-toast nx-mcp-toast-' + (type === 'error' ? 'error' : 'success');
1057 t.textContent = msg;
1058 document.body.appendChild(t);
1059 requestAnimationFrame(function(){ t.classList.add('nx-mcp-toast-in'); });
1060 setTimeout(function(){ t.classList.remove('nx-mcp-toast-in'); setTimeout(function(){ t.remove(); }, 320); }, 3600);
1061 };
1062 window.nxMcpCopy = function(btn, text){
1063 var done = function(){ var o = btn.textContent; btn.textContent = ''; setTimeout(function(){ btn.textContent = o; }, 1200); };
1064 if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, done); }
1065 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(); }
1066 };
1067 window.nxMcpReveal = function(btn){
1068 var code = btn.parentNode.querySelector('.nx-mcp-token'); if(!code) return;
1069 if (code.dataset.shown === '1'){ code.textContent = '••••••••••••'; code.dataset.shown='0'; btn.textContent='Show'; }
1070 else { code.textContent = code.dataset.token || ''; code.dataset.shown='1'; btn.textContent='Hide'; }
1071 };
1072 window.nxMcpAction = function(btn, action, opts){
1073 opts = opts || {};
1074 if (opts.confirm && !window.confirm(opts.confirm)) return;
1075 var old = btn.textContent; btn.disabled = true; btn.textContent = '';
1076 fetch(window.nxMcpData.urls[action], {
1077 method:'POST',
1078 headers:{'Content-Type':'application/json','X-WP-Nonce':window.nxMcpData.nonce},
1079 body: JSON.stringify(opts.body || {})
1080 }).then(function(r){ return r.json().catch(function(){ return {}; }); }).then(function(res){
1081 btn.disabled = false; btn.textContent = old;
1082 if (res && res.status === 'error'){ nxMcpToast('error', res.message || 'Something went wrong.'); return; }
1083 nxMcpToast('success', opts.success || (res && res.message) || 'Done.');
1084 if (opts.reload){ setTimeout(function(){ window.location.reload(); }, 900); }
1085 }).catch(function(){ btn.disabled = false; btn.textContent = old; nxMcpToast('error', 'Request failed.'); });
1086 };
1087 window.nxMcpRevoke = function(btn, type, clientId){
1088 nxMcpAction(btn, 'revoke', {
1089 confirm: 'Revoke this connection? The client will need to reconnect.',
1090 body: { type: type, client_id: clientId },
1091 reload: true,
1092 success: 'Connection revoked.'
1093 });
1094 };
1095 // Keep the status badge in sync with the enable toggle, live.
1096 document.addEventListener('change', function(e){
1097 if (!e.target || e.target.name !== 'enable_mcp') return;
1098 var badge = document.querySelector('.nx-mcp-badge');
1099 if (!badge) return;
1100 var on = !!e.target.checked;
1101 badge.textContent = on ? '<?php echo esc_js( __( 'Active', 'notificationx' ) ); ?>' : '<?php echo esc_js( __( 'Off', 'notificationx' ) ); ?>';
1102 badge.className = 'nx-mcp-badge nx-mcp-badge-' + (on ? 'active' : 'off');
1103 });
1104 </script>
1105 <?php
1106 }
1107
1108 /* --------------------------------------------------------------------- */
1109 /* Helpers */
1110 /* --------------------------------------------------------------------- */
1111
1112 /**
1113 * The request path relative to the WordPress home path, without query string.
1114 *
1115 * @return string
1116 */
1117 protected function request_path() {
1118 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- parsed below.
1119 $uri = esc_url_raw( $uri );
1120 $path = wp_parse_url( $uri, PHP_URL_PATH );
1121 if ( ! $path ) {
1122 return '';
1123 }
1124
1125 $home_path = wp_parse_url( home_url(), PHP_URL_PATH );
1126 if ( $home_path && 0 === strpos( $path, $home_path ) ) {
1127 $path = substr( $path, strlen( $home_path ) );
1128 }
1129
1130 return trim( $path, '/' );
1131 }
1132
1133 /**
1134 * Emit an array as a JSON document and stop.
1135 *
1136 * @param array $data Payload.
1137 * @return void
1138 */
1139 protected function emit_json( $data ) {
1140 nocache_headers();
1141 header( 'Content-Type: application/json; charset=utf-8' );
1142 header( 'Access-Control-Allow-Origin: *' );
1143 header( 'Cache-Control: public, max-age=3600' );
1144 echo wp_json_encode( $data );
1145 exit;
1146 }
1147
1148 /**
1149 * Emit a WP_REST_Response (status + headers + JSON body) and stop.
1150 *
1151 * @param \WP_REST_Response $response Response.
1152 * @return void
1153 */
1154 protected function emit_rest_response( $response ) {
1155 $status = $response->get_status();
1156 $headers = $response->get_headers();
1157 $data = $response->get_data();
1158
1159 if ( ! isset( $headers['Content-Type'] ) ) {
1160 header( 'Content-Type: application/json; charset=utf-8' );
1161 }
1162 foreach ( $headers as $key => $value ) {
1163 header( $key . ': ' . $value );
1164 }
1165 // Set the status LAST. Emitting an auth header such as WWW-Authenticate
1166 // after the status resets the code to 401 in this SAPI, so the status
1167 // must be asserted after every other header() call.
1168 status_header( $status );
1169 if ( function_exists( 'http_response_code' ) ) {
1170 http_response_code( $status );
1171 }
1172 if ( 202 === $status || null === $data ) {
1173 exit;
1174 }
1175 echo wp_json_encode( $data );
1176 exit;
1177 }
1178 }
1179