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

2,155 lines 112.8 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 /**
31 * Path of the pretty MCP endpoint, relative to home.
32 *
33 * Also the suffix of the RFC 9728 discovery URLs we answer for.
34 *
35 * @var string
36 */
37 const ENDPOINT_PATH = 'notificationx/mcp';
38
39 use GetInstance;
40
41 /**
42 * Boot the module. Called from the MCP bootstrap only when the runtime is
43 * capable (PHP version check) — see Bootstrap.
44 *
45 * @return void
46 */
47 public function init() {
48 // Abilities are always registered when the module boots; each is
49 // permission-checked individually and the transport is separately gated.
50 Registrar::get_instance()->boot();
51
52 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
53 add_action( 'parse_request', array( $this, 'handle_front_requests' ), 0 );
54
55 // Admin settings tab (pure PHP field schema; no JS rebuild needed).
56 add_filter( 'nx_settings_tab', array( $this, 'register_settings_tab' ), 20 );
57 add_filter( 'nx_protected_settings', array( $this, 'protect_enable_setting' ) );
58
59 // CSS + JS for the MCP panel (copy / reveal / revoke controls).
60 add_action( 'admin_print_footer_scripts', array( $this, 'print_panel_assets' ) );
61 add_action( 'admin_init', array( $this, 'redirect_hidden_tab' ) );
62 }
63
64 /**
65 * Send a user who cannot see the MCP tab (see register_settings_tab()) from
66 * a `?tab=tab-mcp` link to the settings screen's first tab, rather than to
67 * an empty screen.
68 *
69 * @return void
70 */
71 public function redirect_hidden_tab() {
72 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only navigation check.
73 if ( wp_doing_ajax() || ! isset( $_GET['page'], $_GET['tab'] ) || 'nx-settings' !== $_GET['page'] || 'tab-mcp' !== $_GET['tab'] ) {
74 return;
75 }
76 // phpcs:enable
77 if ( current_user_can( 'manage_options' ) ) {
78 return;
79 }
80 wp_safe_redirect( remove_query_arg( 'tab' ) );
81 exit;
82 }
83
84 /**
85 * Whether MCP access is switched on.
86 *
87 * @return bool
88 */
89 public function is_enabled() {
90 return (bool) Settings::get_instance()->get( 'settings.enable_mcp' );
91 }
92
93 /**
94 * The site's MCP connector URL.
95 *
96 * @return string
97 */
98 public function connector_url() {
99 return home_url( '/notificationx/mcp' );
100 }
101
102 /* --------------------------------------------------------------------- */
103 /* REST routes */
104 /* --------------------------------------------------------------------- */
105
106 /**
107 * Register the transport, OAuth and management routes.
108 *
109 * @return void
110 */
111 public function register_routes() {
112 $ns = 'notificationx/v1';
113
114 // MCP transport — auth happens inside the handler.
115 register_rest_route( $ns, '/mcp', array(
116 'methods' => 'POST',
117 'callback' => array( $this, 'rest_mcp' ),
118 'permission_callback' => '__return_true',
119 ) );
120
121 // OAuth: dynamic client registration + token endpoint (public).
122 // Discovery over REST as well as `/.well-known/`. The well-known path is
123 // a single namespace the whole site shares: another plugin that hooks
124 // `parse_request` earlier, or a host that answers `/.well-known/` itself
125 // (ACME), takes it and our clients then read someone else's metadata.
126 // A route inside our own REST namespace cannot be taken, so that is what
127 // Server::with_challenge() advertises. Public, like the documents
128 // themselves.
129 register_rest_route( $ns, '/mcp/oauth/protected-resource', array(
130 'methods' => 'GET',
131 'callback' => array( $this, 'rest_protected_resource' ),
132 'permission_callback' => '__return_true',
133 ) );
134 register_rest_route( $ns, '/mcp/oauth/authorization-server', array(
135 'methods' => 'GET',
136 'callback' => array( $this, 'rest_authorization_server' ),
137 'permission_callback' => '__return_true',
138 ) );
139 register_rest_route( $ns, '/mcp/oauth/register', array(
140 'methods' => 'POST',
141 'callback' => array( $this, 'rest_oauth_register' ),
142 'permission_callback' => '__return_true',
143 ) );
144 register_rest_route( $ns, '/mcp/oauth/token', array(
145 'methods' => 'POST',
146 'callback' => array( $this, 'rest_oauth_token' ),
147 'permission_callback' => '__return_true',
148 ) );
149
150 // Management (admin only).
151 $admin = array( $this, 'admin_permission' );
152 register_rest_route( $ns, '/mcp/connection', array(
153 'methods' => 'GET',
154 'callback' => array( $this, 'rest_connection' ),
155 'permission_callback' => $admin,
156 ) );
157 register_rest_route( $ns, '/mcp/connect', array(
158 'methods' => 'POST',
159 'callback' => array( $this, 'rest_connect' ),
160 'permission_callback' => $admin,
161 ) );
162 register_rest_route( $ns, '/mcp/rotate', array(
163 'methods' => 'POST',
164 'callback' => array( $this, 'rest_rotate' ),
165 'permission_callback' => $admin,
166 ) );
167 register_rest_route( $ns, '/mcp/disconnect', array(
168 'methods' => 'POST',
169 'callback' => array( $this, 'rest_disconnect' ),
170 'permission_callback' => $admin,
171 ) );
172 register_rest_route( $ns, '/mcp/self-test', array(
173 'methods' => 'POST',
174 'callback' => array( $this, 'rest_self_test' ),
175 'permission_callback' => $admin,
176 ) );
177 // The enable toggle persists through here rather than the settings form.
178 // The settings endpoint replaces the whole settings blob with whatever the
179 // admin app posts (see Admin\Settings::save_settings()), so a request
180 // carrying only `enable_mcp` would wipe every other setting. This writes
181 // the one key and leaves the rest alone.
182 register_rest_route( $ns, '/mcp/enable', array(
183 'methods' => 'POST',
184 'callback' => array( $this, 'rest_set_enabled' ),
185 'permission_callback' => $admin,
186 'args' => array(
187 'enabled' => array(
188 'required' => true,
189 'type' => 'boolean',
190 ),
191 ),
192 ) );
193 register_rest_route( $ns, '/mcp/apps/revoke', array(
194 'methods' => 'POST',
195 'callback' => array( $this, 'rest_revoke_app' ),
196 'permission_callback' => $admin,
197 ) );
198 register_rest_route( $ns, '/mcp/apps', array(
199 'methods' => 'GET',
200 'callback' => array( $this, 'rest_list_apps' ),
201 'permission_callback' => $admin,
202 ) );
203 }
204
205 /**
206 * List the currently connected apps as JSON, so the Connected apps panel can
207 * refresh itself without a full page reload (an app may have been approved or
208 * detached since the page was rendered).
209 *
210 * @return \WP_REST_Response
211 */
212 public function rest_list_apps() {
213 $apps = array();
214 foreach ( $this->get_connected_apps() as $app ) {
215 $apps[] = array(
216 'type' => $app['type'],
217 'client_id' => $app['client_id'],
218 'name' => $app['name'],
219 'read_only' => (bool) $app['read_only'],
220 'scope_label' => $app['read_only'] ? __( 'Read-only', 'notificationx' ) : __( 'Read & write', 'notificationx' ),
221 );
222 }
223
224 return new \WP_REST_Response(
225 array(
226 'status' => 'success',
227 'count' => count( $apps ),
228 'apps' => $apps,
229 ),
230 200
231 );
232 }
233
234 /**
235 * Revoke a single connected app (pairing token or one OAuth client).
236 *
237 * @param \WP_REST_Request $request Request.
238 * @return \WP_REST_Response
239 */
240 public function rest_revoke_app( $request ) {
241 $params = $request->get_json_params() ?: $request->get_body_params();
242 $type = isset( $params['type'] ) ? sanitize_text_field( $params['type'] ) : '';
243
244 if ( 'pairing' === $type ) {
245 Pairing::get_instance()->disconnect();
246 } elseif ( 'oauth' === $type && ! empty( $params['client_id'] ) ) {
247 OAuth::get_instance()->revoke_client( sanitize_text_field( $params['client_id'] ) );
248 } else {
249 return new \WP_REST_Response( array( 'status' => 'error', 'message' => __( 'Nothing to revoke.', 'notificationx' ) ), 400 );
250 }
251
252 return new \WP_REST_Response( array( 'status' => 'success' ), 200 );
253 }
254
255 /**
256 * Management permission: administrators only.
257 *
258 * @return bool
259 */
260 public function admin_permission() {
261 return current_user_can( 'manage_options' );
262 }
263
264 /**
265 * MCP transport handler (REST).
266 *
267 * @param \WP_REST_Request $request Request.
268 * @return \WP_REST_Response
269 */
270 public function rest_mcp( $request ) {
271 return Server::get_instance()->handle( $request );
272 }
273
274 /**
275 * OAuth dynamic client registration handler.
276 *
277 * @param \WP_REST_Request $request Request.
278 * @return \WP_REST_Response|\WP_Error
279 */
280 public function rest_oauth_register( $request ) {
281 if ( ! $this->is_enabled() ) {
282 return new \WP_REST_Response( array( 'error' => 'mcp_disabled' ), 403 );
283 }
284 $result = OAuth::get_instance()->register_client( $request->get_json_params() ?: array() );
285 if ( is_wp_error( $result ) ) {
286 return new \WP_REST_Response( array( 'error' => $result->get_error_code(), 'error_description' => $result->get_error_message() ), 400 );
287 }
288 return new \WP_REST_Response( $result, 201 );
289 }
290
291 /**
292 * OAuth token handler.
293 *
294 * @param \WP_REST_Request $request Request.
295 * @return \WP_REST_Response
296 */
297 public function rest_oauth_token( $request ) {
298 if ( ! $this->is_enabled() ) {
299 return new \WP_REST_Response( array( 'error' => 'mcp_disabled' ), 403 );
300 }
301 // Token requests are form-encoded per OAuth; fall back to JSON.
302 $params = $request->get_body_params();
303 if ( empty( $params ) ) {
304 $params = $request->get_json_params() ?: array();
305 }
306 $result = OAuth::get_instance()->handle_token_request( $params );
307 if ( is_wp_error( $result ) ) {
308 $resp = new \WP_REST_Response( array( 'error' => $result->get_error_code(), 'error_description' => $result->get_error_message() ), 400 );
309 } else {
310 $resp = new \WP_REST_Response( $result, 200 );
311 }
312 $resp->header( 'Cache-Control', 'no-store' );
313 $resp->header( 'Pragma', 'no-cache' );
314 return $resp;
315 }
316
317 /**
318 * Connection status for the admin UI.
319 *
320 * @return \WP_REST_Response
321 */
322 public function rest_connection() {
323 // Whatever switched MCP on -- the toggle, or the settings form's Save --
324 // the panel asks here for the state to display, so make sure there is a
325 // token to hand back rather than reporting an empty one until a reload.
326 $this->ensure_paired();
327
328 return new \WP_REST_Response( $this->connection_state(), 200 );
329 }
330
331 /**
332 * Enable a pairing connection.
333 *
334 * @return \WP_REST_Response
335 */
336 public function rest_connect() {
337 Pairing::get_instance()->connect();
338 return new \WP_REST_Response( array( 'status' => 'success' ) + $this->connection_state(), 200 );
339 }
340
341 /**
342 * Rotate the pairing token.
343 *
344 * @return \WP_REST_Response
345 */
346 public function rest_rotate() {
347 Pairing::get_instance()->rotate();
348 return new \WP_REST_Response( array( 'status' => 'success' ) + $this->connection_state(), 200 );
349 }
350
351 /**
352 * Disconnect: drop the pairing token and revoke all OAuth grants.
353 *
354 * @return \WP_REST_Response
355 */
356 public function rest_disconnect() {
357 Pairing::get_instance()->disconnect();
358 OAuth::get_instance()->revoke_all();
359 return new \WP_REST_Response( array( 'status' => 'success' ), 200 );
360 }
361
362 /**
363 * Run the loopback self-test.
364 *
365 * @return \WP_REST_Response
366 */
367 public function rest_self_test() {
368 // A token is normally minted while the settings tab is built, which only
369 // happens on a server-rendered request. Saving from the admin app never
370 // rebuilds it, so a test run straight after switching MCP on used to
371 // report "no connection token has been generated yet" until the page was
372 // reloaded. Mint here too, so the test reflects the saved state.
373 $this->ensure_paired();
374
375 $result = SelfTest::get_instance()->run();
376 return new \WP_REST_Response( array( 'status' => $result['ok'] ? 'success' : 'error', 'message' => $result['message'] ) + $result, 200 );
377 }
378
379 /**
380 * Turn MCP access on or off.
381 *
382 * Writes only `settings.enable_mcp`: the settings endpoint replaces the whole
383 * blob with the posted one, so persisting the toggle through there would
384 * require the admin app to post every other setting alongside it.
385 *
386 * @param \WP_REST_Request $request Incoming request.
387 * @return \WP_REST_Response
388 */
389 public function rest_set_enabled( $request ) {
390 $enabled = (bool) $request->get_param( 'enabled' );
391
392 $settings = Settings::get_instance()->get( 'settings' );
393 if ( ! is_array( $settings ) ) {
394 $settings = array();
395 }
396 $settings['enable_mcp'] = $enabled;
397 Settings::get_instance()->set( 'settings', $settings );
398
399 // Same courtesy the settings tab does: switching on should leave the UI
400 // with a token to show and a connection that can actually be tested.
401 $this->ensure_paired();
402
403 return new \WP_REST_Response(
404 array( 'status' => 'success' ) + $this->connection_state(),
405 200
406 );
407 }
408
409 /**
410 * Mint a pairing token if MCP is on and none exists yet. Idempotent, and a
411 * no-op while MCP is off so turning it off never creates credentials.
412 *
413 * @return void
414 */
415 protected function ensure_paired() {
416 if ( ! $this->is_enabled() ) {
417 return;
418 }
419 $pairing = Pairing::get_instance();
420 if ( ! $pairing->is_connected() ) {
421 $pairing->connect();
422 }
423 }
424
425 /**
426 * Summarise the connection for the admin UI.
427 *
428 * @return array
429 */
430 protected function connection_state() {
431 $pairing = Pairing::get_instance();
432 return array(
433 'enabled' => $this->is_enabled(),
434 'connected' => $pairing->is_connected(),
435 'connector_url' => $this->connector_url(),
436 'token' => $pairing->site_token(),
437 );
438 }
439
440 /* --------------------------------------------------------------------- */
441 /* Front-end requests: pretty endpoint, discovery, authorize page */
442 /* --------------------------------------------------------------------- */
443
444 /**
445 * Intercept the MCP pretty endpoint, OAuth discovery docs and the
446 * authorize page from the front controller. Path-based so it works under
447 * any permalink structure without rewrite flushes.
448 *
449 * @param \WP $wp WordPress environment.
450 * @return void
451 */
452 public function handle_front_requests( $wp ) {
453 $path = $this->request_path();
454 if ( '' === $path ) {
455 return;
456 }
457
458 // OAuth discovery (also accept the path-suffixed RFC form). Only our
459 // own documents are served, and only while MCP is switched on: another
460 // MCP plugin on the same site owns `.well-known/...`/<its-endpoint>,
461 // and answering that with our metadata would point its clients at our
462 // authorization server. With MCP off we own no resource to describe, so
463 // the request falls through to WordPress instead.
464 if ( $this->is_enabled() ) {
465 if ( $this->owns_discovery_path( $path, 'oauth-authorization-server' ) ) {
466 $this->emit_json( OAuth::get_instance()->authorization_server_metadata() );
467 }
468 if ( $this->owns_discovery_path( $path, 'oauth-protected-resource' ) ) {
469 $this->emit_json( OAuth::get_instance()->protected_resource_metadata() );
470 }
471 }
472
473 // Pretty MCP endpoint.
474 if ( self::ENDPOINT_PATH === $path ) {
475 $this->handle_pretty_mcp();
476 }
477
478 // OAuth authorize consent page.
479 if ( 'notificationx/authorize' === $path ) {
480 $this->handle_authorize();
481 }
482 }
483
484 /**
485 * Handle the pretty MCP endpoint by delegating to the JSON-RPC server.
486 *
487 * @return void
488 */
489 protected function handle_pretty_mcp() {
490 // Only POST carries a JSON-RPC body; a GET is treated as a probe so
491 // clients discovering the endpoint still get a challenge.
492 $request = new \WP_REST_Request( 'POST', '/notificationx/v1/mcp' );
493 $auth = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- header validated downstream.
494 if ( $auth ) {
495 $request->set_header( 'authorization', $auth );
496 }
497 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- raw JSON-RPC body, parsed/validated by the server.
498 $request->set_body( file_get_contents( 'php://input' ) );
499
500 $response = Server::get_instance()->handle( $request );
501 $this->emit_rest_response( $response );
502 }
503
504 /**
505 * Render / process the OAuth authorize consent page.
506 *
507 * @return void
508 */
509 protected function handle_authorize() {
510 if ( ! $this->is_enabled() ) {
511 status_header( 404 );
512 exit;
513 }
514
515 // Require a logged-in administrator; bounce through wp-login if needed.
516 if ( ! is_user_logged_in() ) {
517 $current = ( is_ssl() ? 'https://' : 'http://' ) . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) ) . sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
518 wp_safe_redirect( wp_login_url( $current ) );
519 exit;
520 }
521 if ( ! current_user_can( 'manage_options' ) ) {
522 wp_die( esc_html__( 'You do not have permission to authorize an MCP connection.', 'notificationx' ) );
523 }
524
525 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- these are OAuth request params echoed back into a nonce-protected consent form; no state change on GET.
526 $params = wp_unslash( $_GET );
527 $request = OAuth::get_instance()->validate_authorize_request( $params );
528 if ( is_wp_error( $request ) ) {
529 wp_die( esc_html( $request->get_error_message() ) );
530 }
531
532 $is_post = ( 'POST' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) ) );
533
534 // Deny on POST (nonce-checked): bounce back to the client with the
535 // standard OAuth error so it can end the flow cleanly instead of the
536 // user landing on a dead browser tab.
537 if ( $is_post && isset( $_POST['nx_mcp_deny'] ) ) {
538 check_admin_referer( 'nx_mcp_authorize' );
539 $redirect = add_query_arg(
540 array(
541 'error' => 'access_denied',
542 'error_description' => rawurlencode( 'The user denied the authorization request.' ),
543 'state' => rawurlencode( $request['state'] ),
544 ),
545 $request['redirect_uri']
546 );
547 wp_redirect( $redirect ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- redirect_uri is validated against the registered client allow-list.
548 exit;
549 }
550
551 // Approve on POST (nonce-checked).
552 if ( $is_post && isset( $_POST['nx_mcp_authorize'] ) ) {
553 check_admin_referer( 'nx_mcp_authorize' );
554 $code = OAuth::get_instance()->issue_code( $request, get_current_user_id() );
555 $redirect = add_query_arg(
556 array(
557 'code' => rawurlencode( $code ),
558 'state' => rawurlencode( $request['state'] ),
559 ),
560 $request['redirect_uri']
561 );
562 wp_redirect( $redirect ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- redirect_uri is validated against the registered client allow-list.
563 exit;
564 }
565
566 $this->render_authorize_page( $request );
567 }
568
569 /**
570 * The brand mark for a connecting client.
571 *
572 * Clients arrive through open dynamic registration, so the name is whatever
573 * the app sent and anyone can call themselves "Claude". A vendor mark is
574 * therefore only shown when the name matches AND the code is being sent
575 * back to a host that vendor controls; everything else (including loopback
576 * redirects used by desktop apps) falls back to the initial. The files are
577 * the same ones the Connect a client panel uses, so the consent screen and
578 * the admin panel can never show different marks for the same app.
579 *
580 * @param string $name Registered client name.
581 * @param string $redirect_uri Validated redirect URI of this authorize request.
582 * @return array{file:string,tint:string}|array Empty when unrecognised.
583 */
584 protected static function client_brand( $name, $redirect_uri ) {
585 $brands = array(
586 'claude' => array( 'file' => 'claude.svg', 'tint' => '#fdf1ec', 'hosts' => array( 'claude.ai', 'claude.com', 'anthropic.com' ) ),
587 'chatgpt' => array( 'file' => 'chatgpt.svg', 'tint' => '#eaf6f2', 'hosts' => array( 'chatgpt.com', 'openai.com' ) ),
588 'openai' => array( 'file' => 'chatgpt.svg', 'tint' => '#eaf6f2', 'hosts' => array( 'chatgpt.com', 'openai.com' ) ),
589 'cursor' => array( 'file' => 'cursor.svg', 'tint' => '#eceaf6', 'hosts' => array( 'cursor.com', 'cursor.sh' ) ),
590 );
591 $host = strtolower( (string) wp_parse_url( (string) $redirect_uri, PHP_URL_HOST ) );
592 $scheme = strtolower( (string) wp_parse_url( (string) $redirect_uri, PHP_URL_SCHEME ) );
593 if ( '' === $host || 'https' !== $scheme ) {
594 return array();
595 }
596 foreach ( $brands as $needle => $brand ) {
597 if ( false === stripos( (string) $name, $needle ) ) {
598 continue;
599 }
600 foreach ( $brand['hosts'] as $vendor_host ) {
601 if ( $host === $vendor_host || substr( $host, -strlen( '.' . $vendor_host ) ) === '.' . $vendor_host ) {
602 return array(
603 'file' => $brand['file'],
604 'tint' => $brand['tint'],
605 );
606 }
607 }
608 }
609 return array();
610 }
611
612 /**
613 * Output the consent form.
614 *
615 * @param array $request Validated authorize request.
616 * @return void
617 */
618 protected function render_authorize_page( $request ) {
619 $store = get_option( OAuth::OPTION, array() );
620 $client = isset( $store['clients'][ $request['client_id'] ] ) ? $store['clients'][ $request['client_id'] ] : array();
621 $name = ! empty( $client['client_name'] ) ? $client['client_name'] : $request['client_id'];
622 $scope = $request['scope'];
623
624 // What the granted scope actually permits, in plain language.
625 $read_only = OAuth::get_instance()->scope_is_read_only( $scope );
626
627 // The two ends of the connection: the client app and this site.
628 $client_host = (string) wp_parse_url( $request['redirect_uri'], PHP_URL_HOST );
629 $site_name = get_bloginfo( 'name' );
630 $site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
631
632 // Who is about to approve — everything the connection does is recorded
633 // as this user.
634 $user = wp_get_current_user();
635 $who_name = $user->display_name ? $user->display_name : $user->user_login;
636 $roles = (array) $user->roles;
637 $role_key = $roles ? (string) reset( $roles ) : '';
638 $role_lbl = '';
639 if ( $role_key ) {
640 $wp_roles = wp_roles();
641 if ( isset( $wp_roles->roles[ $role_key ]['name'] ) ) {
642 $role_lbl = translate_user_role( $wp_roles->roles[ $role_key ]['name'] );
643 }
644 }
645 $substr = function_exists( 'mb_substr' ) ? 'mb_substr' : 'substr';
646 $who_initial = strtoupper( $substr( $who_name, 0, 1 ) );
647 $client_initial = strtoupper( $substr( $name, 0, 1 ) );
648 // Show the connecting app's own mark only when we can vouch for it; otherwise the initial.
649 $client_brand = self::client_brand( $name, $request['redirect_uri'] );
650
651 // The exact tools this grant unlocks, straight from the ability
652 // registry so the list can never drift from what the server exposes.
653 Registrar::get_instance()->boot();
654 $granted = array();
655 foreach ( Registrar::get_instance()->get_all() as $ability ) {
656 if ( $read_only && $ability->is_write() ) {
657 continue;
658 }
659 $granted[] = $ability;
660 }
661
662 $cap_label = $read_only ? __( 'Read only', 'notificationx' ) : __( 'Read & write', 'notificationx' );
663 $cap_text = $read_only
664 ? __( 'It can read your notifications, entries and analytics. It cannot create, change or delete anything.', 'notificationx' )
665 : __( 'It acts as you: anything it creates, edits or deletes is recorded under your account.', 'notificationx' );
666
667 // NotificationX brand mark (assets/admin/images/nx-icon.svg), inlined so
668 // the consent page never depends on a second asset request.
669 $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>';
670
671 nocache_headers();
672 header( 'Content-Type: text/html; charset=utf-8' );
673 ?>
674 <!doctype html>
675 <html <?php language_attributes(); ?>>
676 <head>
677 <meta charset="<?php bloginfo( 'charset' ); ?>">
678 <meta name="viewport" content="width=device-width, initial-scale=1">
679 <meta name="robots" content="noindex,nofollow">
680 <title><?php esc_html_e( 'Authorize MCP connection', 'notificationx' ); ?></title>
681 <style>
682 :root{--nx:#6a4bff;--nx-dark:#5614d5;--ink:#1a1a2e;--muted:#5b6072;--line:#e7e7ef}
683 *{box-sizing:border-box}
684 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%)}
685 .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)}
686 .apps{display:flex;align-items:flex-start;justify-content:center;gap:8px;margin:4px 0 22px}
687 .app{width:132px;text-align:center}
688 .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)}
689 .tile.client{background:#eef0f6;color:#3a4056;font-size:26px;font-weight:700}
690 .tile.client svg,.tile.client img{width:38px;height:38px;display:block}
691 .tile.nx{background:#fff;border:1px solid var(--line)}
692 .tile.nx svg{width:42px;height:42px;display:block}
693 .app-name{font-size:14px;font-weight:600;line-height:1.3}
694 .app-host{font-size:12px;color:var(--muted);word-break:break-word;margin-top:2px}
695 .conn{flex:0 0 auto;align-self:center;margin-top:8px;display:flex;align-items:center;gap:6px;color:#b7b9c9}
696 .conn i{display:block;width:14px;height:0;border-top:2px dotted currentColor}
697 .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}
698 h1{font-size:19px;line-height:1.45;margin:0 0 20px;text-align:center;font-weight:600}
699 h1 strong{font-weight:700}
700 .cap{border-radius:14px;padding:16px 16px 14px;border:1px solid #e4defb;background:#f6f3ff}
701 .cap.ro{border-color:#dfe6f2;background:#f2f6fc}
702 .pill{display:inline-block;font-size:12px;font-weight:700;padding:5px 12px;border-radius:999px;background:var(--nx);color:#fff}
703 .cap.ro .pill{background:#3f6fd6}
704 .cap p{margin:11px 0 0;font-size:13px;line-height:1.55;color:#403c5c}
705 .who{display:flex;align-items:center;gap:10px;margin:16px 2px 0;font-size:13px;color:var(--muted)}
706 .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}
707 .who b{color:var(--ink)}
708 details{margin-top:14px;border:1px solid var(--line);border-radius:12px;overflow:hidden}
709 summary{list-style:none;cursor:pointer;padding:13px 15px;font-size:14px;font-weight:600;display:flex;align-items:center;justify-content:space-between}
710 summary::-webkit-details-marker{display:none}
711 summary .chev{transition:transform .15s ease;color:var(--muted)}
712 details[open] summary .chev{transform:rotate(180deg)}
713 .abilities{margin:0;padding:2px 6px 8px;list-style:none}
714 .abilities li{padding:9px 9px;border-top:1px solid var(--line)}
715 .abilities .a-name{font-size:13px;font-weight:600}
716 .abilities .a-desc{font-size:12px;color:var(--muted);margin-top:2px;line-height:1.45}
717 .secured{display:flex;align-items:flex-start;gap:8px;margin:16px 2px 0;font-size:12px;color:var(--muted);line-height:1.5}
718 .secured svg{flex:0 0 auto;margin-top:1px}
719 .actions{display:flex;gap:12px;margin-top:22px}
720 button{flex:1;padding:13px;border-radius:11px;font-size:14px;font-weight:700;cursor:pointer;border:1px solid transparent}
721 .approve{background:var(--nx);color:#fff}
722 .approve:hover{background:var(--nx-dark)}
723 .deny{background:#fff;color:var(--ink);border-color:var(--line)}
724 .deny:hover{background:#f6f6fa}
725 </style>
726 </head>
727 <body>
728 <div class="card">
729 <div class="apps">
730 <div class="app">
731 <div class="tile client<?php echo $client_brand ? ' has-mark' : ''; ?>"<?php echo $client_brand ? ' style="background:' . esc_attr( $client_brand['tint'] ) . '"' : ''; ?>>
732 <?php if ( $client_brand ) : ?>
733 <img src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/' . $client_brand['file'] ); ?>" alt="" width="38" height="38" />
734 <?php else : ?>
735 <?php echo esc_html( $client_initial ); ?>
736 <?php endif; ?>
737 </div>
738 <div class="app-name"><?php echo esc_html( $name ); ?></div>
739 <?php if ( $client_host ) : ?><div class="app-host"><?php echo esc_html( $client_host ); ?></div><?php endif; ?>
740 </div>
741 <div class="conn" aria-hidden="true"><i></i><span class="dot">&rarr;</span><i></i></div>
742 <div class="app">
743 <div class="tile nx"><?php echo $nx_mark; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static inline brand SVG, no dynamic data. ?></div>
744 <div class="app-name">NotificationX</div>
745 <?php if ( $site_host ) : ?><div class="app-host"><?php echo esc_html( $site_host ); ?></div><?php endif; ?>
746 </div>
747 </div>
748
749 <h1>
750 <?php
751 printf(
752 /* translators: %1$s: client app name, %2$s: site name. */
753 esc_html__( '%1$s wants to work with your notifications on %2$s.', 'notificationx' ),
754 '<strong>' . esc_html( $name ) . '</strong>',
755 '<strong>' . esc_html( $site_name ? $site_name : $site_host ) . '</strong>'
756 );
757 ?>
758 </h1>
759
760 <div class="cap <?php echo $read_only ? 'ro' : ''; ?>">
761 <span class="pill"><?php echo esc_html( $cap_label ); ?></span>
762 <p><?php echo esc_html( $cap_text ); ?></p>
763 </div>
764
765 <div class="who">
766 <span class="avatar"><?php echo esc_html( $who_initial ); ?></span>
767 <span>
768 <?php
769 printf(
770 /* translators: %1$s: user display name, %2$s: user role. */
771 esc_html__( 'Signed in as %1$s%2$s', 'notificationx' ),
772 '<b>' . esc_html( $who_name ) . '</b>',
773 $role_lbl ? ' &middot; ' . esc_html( $role_lbl ) : ''
774 );
775 ?>
776 </span>
777 </div>
778
779 <?php if ( $granted ) : ?>
780 <details>
781 <summary>
782 <span>
783 <?php
784 printf(
785 /* translators: %1$s: client app name, %2$d: number of tools. */
786 esc_html__( 'What %1$s will be able to do (%2$d)', 'notificationx' ),
787 esc_html( $name ),
788 count( $granted )
789 );
790 ?>
791 </span>
792 <span class="chev">&#9662;</span>
793 </summary>
794 <ul class="abilities">
795 <?php foreach ( $granted as $ability ) : ?>
796 <li>
797 <div class="a-name"><?php echo esc_html( $ability->get_label() ); ?></div>
798 <div class="a-desc"><?php echo esc_html( $ability->get_description() ); ?></div>
799 </li>
800 <?php endforeach; ?>
801 </ul>
802 </details>
803 <?php endif; ?>
804
805 <div class="secured">
806 <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>
807 <span>
808 <?php esc_html_e( 'Secured with OAuth. You can revoke this app at any time under NotificationX → MCP.', 'notificationx' ); ?>
809 </span>
810 </div>
811
812 <form method="post">
813 <?php wp_nonce_field( 'nx_mcp_authorize' ); ?>
814 <div class="actions">
815 <button type="submit" class="deny" name="nx_mcp_deny" value="1"><?php esc_html_e( 'Deny', 'notificationx' ); ?></button>
816 <button type="submit" class="approve" name="nx_mcp_authorize" value="1"><?php esc_html_e( 'Approve', 'notificationx' ); ?></button>
817 </div>
818 </form>
819 </div>
820 </body>
821 </html>
822 <?php
823 exit;
824 }
825
826 /* --------------------------------------------------------------------- */
827 /* Settings tab (NotificationX admin flow) */
828 /* --------------------------------------------------------------------- */
829
830 /**
831 * Add the "MCP" tab to NotificationX settings.
832 *
833 * @param array $tabs Existing tabs.
834 * @return array
835 */
836 public function register_settings_tab( $tabs ) {
837 // No token is minted here. This runs for every user who can open the
838 // NotificationX admin (and for GET /builder), so minting here bound the
839 // token to whoever loaded a page first, including users who cannot use
840 // it. The manage_options routes (enable, connection, self-test) mint it.
841
842 // The panel prints the connection token, which acts as the administrator
843 // who paired it. Settings access can be delegated to other roles (Role
844 // Management), and every MCP route already requires manage_options, so
845 // the tab is not shown to anyone who could not use those routes.
846 if ( ! current_user_can( 'manage_options' ) ) {
847 return $tabs;
848 }
849
850 $tabs['tab-mcp'] = array(
851 'id' => 'tab-mcp',
852 'label' => __( 'MCP', 'notificationx' ),
853 'priority' => 45,
854 'fields' => $this->settings_fields(),
855 );
856
857 return $tabs;
858 }
859
860 /**
861 * Keep `enable_mcp` out of reach of users who cannot manage MCP.
862 *
863 * The settings form posts the whole blob, so without this a user with
864 * settings access but without manage_options could switch MCP on or off,
865 * although every MCP management route requires manage_options.
866 *
867 * @param array $keys Protected settings keys.
868 * @return array
869 */
870 public function protect_enable_setting( $keys ) {
871 if ( ! current_user_can( 'manage_options' ) ) {
872 $keys = (array) $keys;
873 $keys[] = 'enable_mcp';
874 }
875 return $keys;
876 }
877
878 /**
879 * Build the MCP settings field schema. The rich panels are server-rendered
880 * HTML delivered through quickbuilder `message` fields (html => true); the
881 * action buttons are plain buttons wired to the globals printed by
882 * {@see print_panel_assets()}.
883 *
884 * Section order follows the reading order of someone who has never used the
885 * feature: what it is (hero), what it would give them (capabilities), and
886 * only then the plumbing. The capability section deliberately carries no
887 * `rules`, so the one screen that answers "why would I turn this on?" is
888 * also visible while the feature is still off — the rest stays hidden until
889 * it has something real to show.
890 *
891 * @return array
892 */
893 protected function settings_fields() {
894 $enabled_rule = Rules::is( 'enable_mcp', true );
895
896 $fields = array(
897 'mcp_main_section' => array(
898 'name' => 'mcp_main_section',
899 'type' => 'section',
900 'label' => __( 'MCP Server', 'notificationx' ),
901 'fields' => array(
902 'mcp_hero' => array(
903 'name' => 'mcp_hero',
904 'type' => 'message',
905 'html' => true,
906 'classes' => 'nx-mcp-field nx-mcp-field-flush',
907 'message' => $this->hero_html(),
908 ),
909 'enable_mcp' => array(
910 'name' => 'enable_mcp',
911 'type' => 'toggle',
912 'default' => false,
913 'label' => __( 'Enable MCP access', 'notificationx' ),
914 'help' => __( 'When enabled, approved AI assistants can connect to this site to manage notifications and read analytics.', 'notificationx' ),
915 ),
916 'mcp_stats' => array(
917 'name' => 'mcp_stats',
918 'type' => 'message',
919 'html' => true,
920 'classes' => 'nx-mcp-field',
921 'rules' => $enabled_rule,
922 'message' => $this->stats_html(),
923 ),
924 ),
925 ),
926
927 'mcp_connection_section' => array(
928 'name' => 'mcp_connection_section',
929 'type' => 'section',
930 'label' => __( 'Connection', 'notificationx' ),
931 'rules' => $enabled_rule,
932 'fields' => array(
933 'mcp_connection_html' => array(
934 'name' => 'mcp_connection_html',
935 'type' => 'message',
936 'html' => true,
937 'classes' => 'nx-mcp-field',
938 'message' => $this->connection_html(),
939 ),
940 ),
941 ),
942
943 'mcp_clients_section' => array(
944 'name' => 'mcp_clients_section',
945 'type' => 'section',
946 'label' => __( 'Connect a client', 'notificationx' ),
947 'rules' => $enabled_rule,
948 'fields' => array(
949 'mcp_clients_html' => array(
950 'name' => 'mcp_clients_html',
951 'type' => 'message',
952 'html' => true,
953 'classes' => 'nx-mcp-field',
954 'message' => $this->clients_html(),
955 ),
956 ),
957 ),
958
959 'mcp_apps_section' => array(
960 'name' => 'mcp_apps_section',
961 'type' => 'section',
962 'label' => __( 'Connected apps', 'notificationx' ),
963 'rules' => $enabled_rule,
964 'fields' => array(
965 'mcp_apps_html' => array(
966 'name' => 'mcp_apps_html',
967 'type' => 'message',
968 'html' => true,
969 'classes' => 'nx-mcp-field',
970 'message' => $this->connected_apps_html(),
971 ),
972 ),
973 ),
974
975 'mcp_health_section' => array(
976 'name' => 'mcp_health_section',
977 'type' => 'section',
978 'label' => __( 'Connection health', 'notificationx' ),
979 'rules' => $enabled_rule,
980 'fields' => array(
981 'mcp_health_html' => array(
982 'name' => 'mcp_health_html',
983 'type' => 'message',
984 'html' => true,
985 'classes' => 'nx-mcp-field',
986 'message' => $this->health_html(),
987 ),
988 ),
989 ),
990 );
991
992 return $fields;
993 }
994
995 /**
996 * Current status: off | setup | active.
997 *
998 * @return array [ state, label ]
999 */
1000 protected function status() {
1001 if ( ! $this->is_enabled() ) {
1002 return array( 'off', __( 'Off', 'notificationx' ) );
1003 }
1004 if ( Pairing::get_instance()->is_connected() ) {
1005 return array( 'active', __( 'Active', 'notificationx' ) );
1006 }
1007 return array( 'setup', __( 'Setup needed', 'notificationx' ) );
1008 }
1009
1010 /**
1011 * The abilities currently registered, split the way the panel reads them.
1012 *
1013 * Read straight from the registry rather than a hand-kept list, so the tab
1014 * can never claim a tool the server does not actually expose — and so Pro's
1015 * abilities appear the moment Pro adds them through `nx_register_abilities`
1016 * with no change here. `boot()` is idempotent, and calling it is what makes
1017 * this safe to render on a request where nothing else has touched the
1018 * registry yet.
1019 *
1020 * @return array { read: array[], write: array[] } each row: label, tool, pro.
1021 */
1022 protected function ability_rows() {
1023 $registrar = Registrar::get_instance();
1024 $registrar->boot();
1025
1026 $rows = array(
1027 'read' => array(),
1028 'write' => array(),
1029 );
1030
1031 foreach ( $registrar->get_all() as $id => $ability ) {
1032 $row = array(
1033 'label' => $ability->get_label(),
1034 'tool' => $ability->tool_name(),
1035 'pro' => ( 0 === strpos( (string) $id, 'notificationx-pro/' ) ),
1036 );
1037
1038 $rows[ $ability->is_write() ? 'write' : 'read' ][] = $row;
1039 }
1040
1041 return $rows;
1042 }
1043
1044 /**
1045 * The three setup steps shown as a static how-to in the hero.
1046 *
1047 * @return array[] Each: icon, label, hint.
1048 */
1049 protected function setup_steps() {
1050 return array(
1051 array(
1052 'icon' => 'icon-step-power',
1053 'label' => __( 'Turn MCP on', 'notificationx' ),
1054 'hint' => __( 'Flip the switch below.', 'notificationx' ),
1055 ),
1056 array(
1057 'icon' => 'icon-step-copy',
1058 'label' => __( 'Copy your connector', 'notificationx' ),
1059 'hint' => __( 'One URL, and a token for clients that need one.', 'notificationx' ),
1060 ),
1061 array(
1062 'icon' => 'icon-step-approve',
1063 'label' => __( 'Approve the client', 'notificationx' ),
1064 'hint' => __( 'Add it in Claude, ChatGPT or Cursor and confirm.', 'notificationx' ),
1065 ),
1066 );
1067 }
1068
1069 /**
1070 * Path to one of the tab's own icon files.
1071 *
1072 * The panel HTML is rendered into the settings app through a `message`
1073 * field, where an inline `<svg>` does not survive: icons are therefore real
1074 * files referenced with `<img>`, never markup and never a `data:` URI.
1075 *
1076 * @param string $name File name, without extension.
1077 * @return string
1078 */
1079 protected function icon_url( $name ) {
1080 return NOTIFICATIONX_ADMIN_URL . 'images/mcp/' . $name . '.svg';
1081 }
1082
1083 /**
1084 * Hero header: what the feature is, where the site currently stands, and
1085 * the three steps between here and a working connection.
1086 *
1087 * @return string
1088 */
1089 protected function hero_html() {
1090 list( $state, $label ) = $this->status();
1091 $steps = $this->setup_steps();
1092 ob_start();
1093 ?>
1094 <div class="nx-mcp-hero nx-mcp-hero-<?php echo esc_attr( $state ); ?>">
1095 <div class="nx-mcp-hero-main">
1096 <span class="nx-mcp-hero-tile">
1097 <img class="nx-mcp-hero-tile-ic" width="24" height="24" alt="" src="<?php echo esc_url( $this->icon_url( 'icon-mcp' ) ); ?>" />
1098 </span>
1099 <div class="nx-mcp-hero-body">
1100 <h3 class="nx-mcp-hero-title">
1101 <?php esc_html_e( 'Run NotificationX from your AI assistant', 'notificationx' ); ?>
1102 <span class="nx-mcp-badge nx-mcp-badge-<?php echo esc_attr( $state ); ?>">
1103 <span class="nx-mcp-badge-dot" aria-hidden="true"></span>
1104 <span class="nx-mcp-badge-text"><?php echo esc_html( $label ); ?></span>
1105 </span>
1106 </h3>
1107 <p class="nx-mcp-hero-text">
1108 <?php esc_html_e( 'A built-in MCP server lets Claude, ChatGPT, Cursor and other assistants build campaigns, flip notifications on or off and read your analytics — in plain language, without leaving the chat. It stays off until you switch it on, and only administrators can connect.', 'notificationx' ); ?>
1109 </p>
1110 <a class="nx-mcp-learn" href="<?php echo esc_url( 'https://notificationx.com/docs/mcp-in-notificationx' ); ?>" target="_blank" rel="noopener noreferrer">
1111 <span class="nx-mcp-learn-text"><?php esc_html_e( 'Learn how it works', 'notificationx' ); ?></span>
1112 <span class="nx-mcp-learn-arrow" aria-hidden="true">&rarr;</span>
1113 </a>
1114 </div>
1115 </div>
1116 <ol class="nx-mcp-rail">
1117 <?php foreach ( $steps as $step ) : ?>
1118 <li class="nx-mcp-rail-step">
1119 <span class="nx-mcp-rail-mark" aria-hidden="true">
1120 <img class="nx-mcp-rail-ic" width="16" height="16" alt="" src="<?php echo esc_url( $this->icon_url( $step['icon'] ) ); ?>" />
1121 </span>
1122 <span class="nx-mcp-rail-body">
1123 <strong class="nx-mcp-rail-label"><?php echo esc_html( $step['label'] ); ?></strong>
1124 <span class="nx-mcp-rail-hint"><?php echo esc_html( $step['hint'] ); ?></span>
1125 </span>
1126 </li>
1127 <?php endforeach; ?>
1128 </ol>
1129 </div>
1130 <?php
1131 return ob_get_clean();
1132 }
1133
1134 /**
1135 * The four headline numbers, each one read from state the page already has.
1136 *
1137 * There is deliberately no trend line or delta anywhere on this row: the
1138 * server keeps a single `last_used` stamp and no history at all, so a trend
1139 * here could only be invented.
1140 *
1141 * @return string
1142 */
1143 protected function stats_html() {
1144 list( $state, $status_label ) = $this->status();
1145
1146 $pairing = Pairing::get_instance();
1147 $pstate = $pairing->state();
1148 $connected_at = ! empty( $pstate['connected_at'] ) ? (int) $pstate['connected_at'] : 0;
1149 $last_used = ! empty( $pstate['last_used'] ) ? (int) $pstate['last_used'] : 0;
1150
1151 $rows = $this->ability_rows();
1152 $tool_count = count( $rows['read'] ) + count( $rows['write'] );
1153 $pro_count = 0;
1154 foreach ( array_merge( $rows['read'], $rows['write'] ) as $row ) {
1155 if ( $row['pro'] ) {
1156 ++$pro_count;
1157 }
1158 }
1159
1160 $apps = count( $this->get_connected_apps() );
1161
1162 $tiles = array(
1163 array(
1164 'key' => 'status',
1165 'icon' => 'icon-status',
1166 'label' => __( 'Server status', 'notificationx' ),
1167 'value' => $status_label,
1168 'small' => true,
1169 'note' => $connected_at
1170 /* translators: %s: the date the connection was established. */
1171 ? sprintf( __( 'since %s', 'notificationx' ), date_i18n( get_option( 'date_format' ), $connected_at ) )
1172 : '',
1173 ),
1174 array(
1175 'icon' => 'icon-tools',
1176 'label' => __( 'Tools exposed', 'notificationx' ),
1177 'value' => number_format_i18n( $tool_count ),
1178 'small' => false,
1179 'note' => $pro_count
1180 /* translators: %s: number of Pro-only tools. */
1181 ? sprintf( _n( '%s from Pro', '%s from Pro', $pro_count, 'notificationx' ), number_format_i18n( $pro_count ) )
1182 : __( 'more with Pro', 'notificationx' ),
1183 ),
1184 array(
1185 'icon' => 'icon-apps',
1186 'label' => __( 'Connected apps', 'notificationx' ),
1187 'value' => number_format_i18n( $apps ),
1188 'small' => false,
1189 'note' => $apps ? '' : __( 'none yet', 'notificationx' ),
1190 ),
1191 array(
1192 'icon' => 'icon-activity',
1193 'label' => __( 'Last activity', 'notificationx' ),
1194 'value' => $last_used
1195 /* translators: %s: human-readable time difference, e.g. "5 mins". */
1196 ? sprintf( __( '%s ago', 'notificationx' ), human_time_diff( $last_used ) )
1197 : __( 'Never', 'notificationx' ),
1198 'small' => true,
1199 'note' => '',
1200 ),
1201 );
1202
1203 ob_start();
1204 ?>
1205 <div class="nx-mcp-stats nx-mcp-stats-<?php echo esc_attr( $state ); ?>">
1206 <?php foreach ( $tiles as $tile ) : ?>
1207 <div class="nx-mcp-stat">
1208 <div class="nx-mcp-stat-top">
1209 <span class="nx-mcp-stat-label"><?php echo esc_html( $tile['label'] ); ?></span>
1210 <span class="nx-mcp-stat-ic">
1211 <img width="16" height="16" alt="" src="<?php echo esc_url( $this->icon_url( $tile['icon'] ) ); ?>" />
1212 </span>
1213 </div>
1214 <div class="nx-mcp-stat-row">
1215 <span class="nx-mcp-stat-value<?php echo $tile['small'] ? ' is-sm' : ''; ?><?php echo isset( $tile['key'] ) ? ' nx-mcp-stat-' . esc_attr( $tile['key'] ) : ''; ?>"><?php echo esc_html( $tile['value'] ); ?></span>
1216 <?php if ( $tile['note'] ) : ?>
1217 <span class="nx-mcp-stat-note"><?php echo esc_html( $tile['note'] ); ?></span>
1218 <?php endif; ?>
1219 </div>
1220 </div>
1221 <?php endforeach; ?>
1222 </div>
1223 <?php
1224 return ob_get_clean();
1225 }
1226
1227 /**
1228 * Connector URL + token cards with copy/reveal controls.
1229 *
1230 * Always rendered, even while MCP is off: the enable toggle saves itself and
1231 * hands back the token, which nxMcpSetToken() writes into these cards, so
1232 * the panel works without a reload.
1233 *
1234 * @return string
1235 */
1236 protected function connection_html() {
1237 // The token is deliberately not printed here. This markup is part of the
1238 // settings schema, which reaches every user who can open the
1239 // NotificationX admin (and GET /builder), not only administrators.
1240 // Show/Copy fetch it from GET /mcp/connection, which requires manage_options.
1241 $url = $this->connector_url();
1242 ob_start();
1243 ?>
1244 <div class="nx-mcp-grid">
1245 <div class="nx-mcp-card">
1246 <span class="nx-mcp-card-label"><?php esc_html_e( 'Connector URL', 'notificationx' ); ?></span>
1247 <div class="nx-mcp-copyrow">
1248 <code class="nx-mcp-value"><?php echo esc_html( $url ); ?></code>
1249 <button type="button" class="nx-mcp-copy" onclick="nxMcpCopy(this,'<?php echo esc_js( $url ); ?>')"><?php esc_html_e( 'Copy', 'notificationx' ); ?></button>
1250 </div>
1251 <p class="nx-mcp-hint"><?php esc_html_e( 'Add this URL as a custom connector in your AI client.', 'notificationx' ); ?></p>
1252 </div>
1253 <div class="nx-mcp-card">
1254 <span class="nx-mcp-card-label"><?php esc_html_e( 'Connection token', 'notificationx' ); ?></span>
1255 <div class="nx-mcp-copyrow">
1256 <code class="nx-mcp-value nx-mcp-token" data-token="">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</code>
1257 <button type="button" class="nx-mcp-copy" onclick="nxMcpReveal(this)"><?php esc_html_e( 'Show', 'notificationx' ); ?></button>
1258 <?php // Reads the token from the element rather than a value baked in at render time: the panel is built before MCP is switched on, so a literal here would stay empty until a reload. ?>
1259 <button type="button" class="nx-mcp-copy" onclick="nxMcpCopyToken(this)"><?php esc_html_e( 'Copy', 'notificationx' ); ?></button>
1260 </div>
1261 <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>
1262 </div>
1263 </div>
1264 <div class="nx-mcp-actions">
1265 <button type="button" class="nx-mcp-btn nx-mcp-btn-primary" onclick="nxMcpAction(this,'test',{result:'nx-mcp-testresult',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>
1266 <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>
1267 </div>
1268 <div class="nx-mcp-result" id="nx-mcp-testresult"></div>
1269 <?php
1270 return ob_get_clean();
1271 }
1272
1273 /**
1274 * Per-client setup cards.
1275 *
1276 * Each card ends in a copy control that hands over exactly what that client
1277 * asks for — a URL for the two that take one, and a ready-made server block
1278 * for the config-file clients. The token is never baked into these strings:
1279 * the handler reads it from the token field already on the page, so this
1280 * panel adds no second copy of the secret to the document.
1281 *
1282 * @return string
1283 */
1284 protected function clients_html() {
1285 $url = $this->connector_url();
1286 ob_start();
1287 ?>
1288 <div class="nx-mcp-clients">
1289 <div class="nx-mcp-client">
1290 <div class="nx-mcp-client-name">
1291 <img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/claude.svg' ); ?>" />
1292 <span class="nx-mcp-client-title"><?php esc_html_e( 'Claude', 'notificationx' ); ?></span>
1293 <span class="nx-mcp-pill nx-mcp-pill-oauth"><?php esc_html_e( 'OAuth', 'notificationx' ); ?></span>
1294 </div>
1295 <ol class="nx-mcp-steps">
1296 <li><?php esc_html_e( 'In Claude, add a custom connector.', 'notificationx' ); ?></li>
1297 <li><?php esc_html_e( 'Paste the Connector URL above.', 'notificationx' ); ?></li>
1298 <li><?php esc_html_e( 'Approve the connection when prompted — you sign in here, no token needed.', 'notificationx' ); ?></li>
1299 </ol>
1300 <button type="button" class="nx-mcp-copy nx-mcp-copy-wide" onclick="nxMcpCopy(this,'<?php echo esc_js( $url ); ?>')"><?php esc_html_e( 'Copy connector URL', 'notificationx' ); ?></button>
1301 </div>
1302 <div class="nx-mcp-client">
1303 <div class="nx-mcp-client-name">
1304 <img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/chatgpt.svg' ); ?>" />
1305 <span class="nx-mcp-client-title"><?php esc_html_e( 'ChatGPT', 'notificationx' ); ?></span>
1306 <span class="nx-mcp-pill nx-mcp-pill-token"><?php esc_html_e( 'Token', 'notificationx' ); ?></span>
1307 </div>
1308 <ol class="nx-mcp-steps">
1309 <li><?php esc_html_e( 'Settings → Connectors → Add a custom connector.', 'notificationx' ); ?></li>
1310 <li><?php esc_html_e( 'Use the Connector URL above.', 'notificationx' ); ?></li>
1311 <li><?php esc_html_e( 'Provide the connection token as a Bearer credential.', 'notificationx' ); ?></li>
1312 </ol>
1313 <button type="button" class="nx-mcp-copy nx-mcp-copy-wide" onclick="nxMcpCopy(this,'<?php echo esc_js( $url ); ?>')"><?php esc_html_e( 'Copy connector URL', 'notificationx' ); ?></button>
1314 </div>
1315 <div class="nx-mcp-client">
1316 <div class="nx-mcp-client-name">
1317 <img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/cursor.svg' ); ?>" />
1318 <span class="nx-mcp-client-title"><?php esc_html_e( 'Cursor &amp; others', 'notificationx' ); ?></span>
1319 <span class="nx-mcp-pill nx-mcp-pill-token"><?php esc_html_e( 'Token', 'notificationx' ); ?></span>
1320 </div>
1321 <ol class="nx-mcp-steps">
1322 <li><?php esc_html_e( 'Open the client’s MCP configuration file.', 'notificationx' ); ?></li>
1323 <li><?php esc_html_e( 'Paste the server block below into mcpServers.', 'notificationx' ); ?></li>
1324 <li><?php esc_html_e( 'Confirm the install when the client asks.', 'notificationx' ); ?></li>
1325 </ol>
1326 <button type="button" class="nx-mcp-copy nx-mcp-copy-wide nx-mcp-copy-config" data-url="<?php echo esc_attr( $url ); ?>"><?php esc_html_e( 'Copy JSON config', 'notificationx' ); ?></button>
1327 </div>
1328 </div>
1329 <?php
1330 return ob_get_clean();
1331 }
1332
1333 /**
1334 * The currently connected apps (pairing token + active OAuth clients). Shared
1335 * by the rendered panel and the /mcp/apps endpoint so the two cannot drift.
1336 *
1337 * @return array[] Each: type, client_id, name, read_only.
1338 */
1339 protected function get_connected_apps() {
1340 $apps = array();
1341
1342 // Only list the token connection once a client has actually used it —
1343 // the token existing on its own is not a "connected app".
1344 $pairing = Pairing::get_instance();
1345 $pstate = $pairing->state();
1346 if ( $pairing->is_connected() && ! empty( $pstate['last_used'] ) ) {
1347 $apps[] = array(
1348 'type' => 'pairing',
1349 'client_id' => '',
1350 'name' => __( 'Token connection (ChatGPT / Cursor / manual)', 'notificationx' ),
1351 'read_only' => $pairing->is_read_only(),
1352 );
1353 }
1354 foreach ( OAuth::get_instance()->list_active_clients() as $client ) {
1355 $apps[] = array(
1356 'type' => 'oauth',
1357 'client_id' => $client['client_id'],
1358 'name' => $client['name'],
1359 'read_only' => ! empty( $client['read_only'] ),
1360 );
1361 }
1362
1363 return $apps;
1364 }
1365
1366 protected function connected_apps_html() {
1367 $apps = $this->get_connected_apps();
1368
1369 ob_start();
1370 ?>
1371 <div class="nx-mcp-apps-head">
1372 <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>
1373 <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>
1374 </div>
1375 <div id="nx-mcp-apps-wrap">
1376 <?php
1377 if ( empty( $apps ) ) {
1378 echo '<p class="nx-mcp-empty">' . esc_html__( 'No AI clients are connected yet.', 'notificationx' ) . '</p>';
1379 } else {
1380 echo '<div class="nx-mcp-apps">';
1381 foreach ( $apps as $app ) {
1382 $scope_class = $app['read_only'] ? 'nx-mcp-scope-ro' : 'nx-mcp-scope-rw';
1383 $scope_label = $app['read_only'] ? __( 'Read-only', 'notificationx' ) : __( 'Read & write', 'notificationx' );
1384 ?>
1385 <div class="nx-mcp-app" data-nx-key="<?php echo esc_attr( $app['type'] . ':' . $app['client_id'] ); ?>">
1386 <div class="nx-mcp-app-info">
1387 <strong><?php echo esc_html( $app['name'] ); ?></strong>
1388 <span class="nx-mcp-scope <?php echo esc_attr( $scope_class ); ?>"><?php echo esc_html( $scope_label ); ?></span>
1389 </div>
1390 <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>
1391 </div>
1392 <?php
1393 }
1394 echo '</div>';
1395 }
1396 ?>
1397 </div>
1398 <?php
1399 return ob_get_clean();
1400 }
1401
1402 /**
1403 * Connection health panel.
1404 *
1405 * @return string
1406 */
1407 protected function health_html() {
1408 $secure = is_ssl();
1409 ob_start();
1410 ?>
1411 <div class="nx-mcp-health">
1412 <div class="nx-mcp-health-row">
1413 <span class="nx-mcp-dot <?php echo $secure ? 'nx-mcp-dot-good' : 'nx-mcp-dot-warn'; ?>"></span>
1414 <?php if ( $secure ) : ?>
1415 <?php esc_html_e( 'Secure connection (HTTPS) is on.', 'notificationx' ); ?>
1416 <?php else : ?>
1417 <?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' ); ?>
1418 <?php endif; ?>
1419 </div>
1420 <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>
1421 <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>
1422 <p class="nx-mcp-hint"><?php esc_html_e( 'Use “Test connection” above to verify the server end-to-end.', 'notificationx' ); ?></p>
1423 </div>
1424 <div class="nx-mcp-danger">
1425 <div class="nx-mcp-danger-text">
1426 <strong><?php esc_html_e( 'Disconnect all', 'notificationx' ); ?></strong>
1427 <span><?php esc_html_e( 'Revoke every connection and OAuth grant. All clients will need to reconnect.', 'notificationx' ); ?></span>
1428 </div>
1429 <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>
1430 </div>
1431 <?php
1432 return ob_get_clean();
1433 }
1434
1435 /**
1436 * Print the MCP panel CSS + JS on the NotificationX settings page.
1437 * (The onclick handlers in the rendered HTML reference these globals.)
1438 *
1439 * @return void
1440 */
1441 public function print_panel_assets() {
1442 // The NotificationX admin is a single-page app (BrowserRouter): moving
1443 // between its screens — including into Settings → MCP — is client-side, so
1444 // admin_print_footer_scripts fires only on the first full page load,
1445 // whatever NX screen that happened to be. Print the panel CSS/JS on every
1446 // NotificationX admin page (slug prefixed "nx-"), not just nx-settings, so
1447 // the styles/handlers are already on the document when the MCP tab renders
1448 // after a client-side navigation. Otherwise the panel shows unstyled until
1449 // a manual reload.
1450 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check.
1451 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
1452 if ( ! is_admin() || 0 !== strpos( $page, 'nx-' ) || ! current_user_can( 'manage_options' ) ) {
1453 return;
1454 }
1455 $nonce = wp_create_nonce( 'wp_rest' );
1456 $urls = array(
1457 'test' => esc_url_raw( rest_url( 'notificationx/v1/mcp/self-test' ) ),
1458 'enable' => esc_url_raw( rest_url( 'notificationx/v1/mcp/enable' ) ),
1459 'connection' => esc_url_raw( rest_url( 'notificationx/v1/mcp/connection' ) ),
1460 'rotate' => esc_url_raw( rest_url( 'notificationx/v1/mcp/rotate' ) ),
1461 'disconnect' => esc_url_raw( rest_url( 'notificationx/v1/mcp/disconnect' ) ),
1462 'revoke' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps/revoke' ) ),
1463 'apps' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps' ) ),
1464 );
1465 $i18n = array(
1466 'revoke' => __( 'Revoke', 'notificationx' ),
1467 'empty' => __( 'No AI clients are connected yet.', 'notificationx' ),
1468 'refreshFailed' => __( 'Could not refresh the connected apps.', 'notificationx' ),
1469 'revokeConfirm' => __( 'Revoke this connection? The client will need to reconnect.', 'notificationx' ),
1470 // Enable toggle outcomes.
1471 'enabled' => __( 'MCP access enabled.', 'notificationx' ),
1472 'disabled' => __( 'MCP access disabled.', 'notificationx' ),
1473 'enableFailed' => __( 'Could not save the MCP setting.', 'notificationx' ),
1474 // Generic action outcomes.
1475 'genericError' => __( 'Something went wrong.', 'notificationx' ),
1476 'requestFailed' => __( 'Request failed.', 'notificationx' ),
1477 'done' => __( 'Done.', 'notificationx' ),
1478 'revoked' => __( 'Connection revoked.', 'notificationx' ),
1479 // Refresh outcomes: say what actually changed, not just a count.
1480 'noneStill' => __( 'No apps connected yet.', 'notificationx' ),
1481 'upToDate' => __( 'Up to date — nothing changed.', 'notificationx' ),
1482 'addedOne' => __( '1 new app connected.', 'notificationx' ),
1483 /* translators: %d: number of newly connected apps. */
1484 'addedMany' => __( '%d new apps connected.', 'notificationx' ),
1485 'removedOne' => __( '1 app disconnected.', 'notificationx' ),
1486 /* translators: %d: number of disconnected apps. */
1487 'removedMany' => __( '%d apps disconnected.', 'notificationx' ),
1488 'changed' => __( 'Connected apps updated.', 'notificationx' ),
1489 'statusActive' => __( 'Active', 'notificationx' ),
1490 'statusOff' => __( 'Off', 'notificationx' ),
1491 'copied' => __( 'Copied', 'notificationx' ),
1492 'tokenMissing' => __( 'The token is not on screen yet. Reload the page and try again.', 'notificationx' ),
1493 'configCopied' => __( 'Server block copied. Paste it into your client’s MCP config.', 'notificationx' ),
1494 );
1495 ?>
1496 <style id="nx-mcp-panel-css">
1497 /* The settings form renders a message field's HTML inside a <p>, which
1498 carries the form's own paragraph spacing: reset it on our own fields
1499 so the panels control their own rhythm. */
1500 .nx-mcp-field p{margin:0}
1501 .nx-mcp-field-flush > p{margin:0}
1502
1503 /* NotificationX's own `#notificationx .wprf-message p {font-size:16px}`
1504 outranks a bare class, so every paragraph and list item inside a panel
1505 would silently come back at the form's body size — which is what made
1506 the old hint text read as body copy. These carry the same id plus the
1507 class, so the panel keeps the type scale it was designed at without
1508 reaching for !important. The unprefixed rules further down stay as the
1509 fallback for anywhere the `#notificationx` root is absent. */
1510 #notificationx .wprf-message p.nx-mcp-hero-text{font-size:13.5px;line-height:1.65}
1511 #notificationx .wprf-message p.nx-mcp-hint{font-size:12px;line-height:1.55}
1512 #notificationx .wprf-message p.nx-mcp-empty{font-size:13px}
1513 #notificationx .wprf-message ol.nx-mcp-steps,#notificationx .wprf-message ol.nx-mcp-steps li{font-size:12.5px;line-height:1.75}
1514 #notificationx .wprf-message ol.nx-mcp-rail,#notificationx .wprf-message ol.nx-mcp-rail li{font-size:12.5px}
1515
1516 /* ---- Hero -------------------------------------------------------- */
1517 .nx-mcp-hero{border-radius:14px;overflow:hidden;background:linear-gradient(135deg,#f6f3ff 0%,#fbfaff 55%,#ffffff 100%);color:#1d2327;border:1px solid #e4ddff;box-shadow:0 6px 20px rgba(106,75,255,.08)}
1518 .nx-mcp-hero-main{display:flex;gap:16px;align-items:flex-start;padding:22px 24px 20px}
1519 .nx-mcp-hero-tile{width:44px;height:44px;flex:none;border-radius:12px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#6a4bff,#8b6bff);border:0;box-shadow:0 4px 12px rgba(106,75,255,.28)}
1520 .nx-mcp-hero-tile-ic{width:24px;height:24px;display:block}
1521 .nx-mcp-hero-body{min-width:0}
1522 .nx-mcp-hero-title{margin:0 0 8px;font-size:19px;line-height:1.3;font-weight:700;color:#1d2327;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
1523 .nx-mcp-hero-text{margin:0;color:#50575e;font-size:13.5px;line-height:1.65;max-width:720px}
1524 .nx-mcp-learn{display:inline-flex;align-items:center;gap:5px;margin-top:12px;color:#5a3ee6;font-size:13px;font-weight:600}
1525 /* The message-field CSS (#notificationx .wprf-message p a) underlines the
1526 whole anchor at rest, which draws a line under the arrow too. Override
1527 it in every state (!important beats that #id rule) and underline only
1528 the text span on hover. */
1529 .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;color:#5a3ee6!important}
1530 .nx-mcp-learn .nx-mcp-learn-text{text-decoration:none}
1531 .nx-mcp-learn:hover .nx-mcp-learn-text{text-decoration:underline}
1532 .nx-mcp-learn-arrow{display:inline-block;transition:transform .2s}
1533 .nx-mcp-learn:hover .nx-mcp-learn-arrow{transform:translateX(3px)}
1534 /* The glyph is a literal right arrow: mirror it, and the nudge, in RTL. */
1535 [dir="rtl"] .nx-mcp-learn-arrow{transform:scaleX(-1)}
1536 [dir="rtl"] .nx-mcp-learn:hover .nx-mcp-learn-arrow{transform:scaleX(-1) translateX(3px)}
1537
1538 /* ---- Status badge ------------------------------------------------ */
1539 .nx-mcp-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:700;padding:3px 11px;border-radius:999px;text-transform:uppercase;letter-spacing:.04em;white-space:nowrap}
1540 .nx-mcp-badge-dot{width:7px;height:7px;border-radius:50%;flex:none;background:currentColor}
1541 .nx-mcp-badge-off{background:#eef0f3;color:#50575e}
1542 .nx-mcp-badge-active{background:#d8f7e2;color:#127a35}
1543 .nx-mcp-badge-setup{background:#ffeccc;color:#8a5a00}
1544 .nx-mcp-badge-active .nx-mcp-badge-dot{animation:nx-mcp-pulse 1.8s ease-in-out infinite}
1545 @keyframes nx-mcp-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.72)}}
1546 @media(prefers-reduced-motion:reduce){.nx-mcp-badge-active .nx-mcp-badge-dot{animation:none}}
1547
1548 /* ---- Setup rail -------------------------------------------------- */
1549 .nx-mcp-rail{display:grid;grid-template-columns:repeat(3,1fr);gap:0;margin:0;padding:0;list-style:none;background:transparent;border-top:1px solid #ece8ff}
1550 .nx-mcp-rail-step{display:flex;gap:12px;align-items:center;padding:14px 20px;margin:0;position:relative}
1551 .nx-mcp-rail-step + .nx-mcp-rail-step{border-inline-start:1px solid #ece8ff}
1552 .nx-mcp-rail-mark{width:32px;height:32px;flex:none;border-radius:9px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#6a4bff 0%,#8b6bff 100%);box-shadow:0 3px 8px rgba(106,75,255,.25)}
1553 .nx-mcp-rail-ic{width:16px;height:16px;display:block}
1554 .nx-mcp-rail-body{display:flex;flex-direction:column;gap:2px;min-width:0}
1555 .nx-mcp-rail-label{font-size:12.5px;font-weight:700;color:#1d2327}
1556 .nx-mcp-rail-hint{font-size:11.5px;line-height:1.5;color:#646970}
1557 @media(max-width:782px){.nx-mcp-rail{grid-template-columns:1fr}.nx-mcp-rail-step + .nx-mcp-rail-step{border-inline-start:0;border-top:1px solid #ece8ff}}
1558
1559 /* ---- Enable toggle row ------------------------------------------- */
1560 /* Keep label + switch on one row (no fixed 200px label column gap) and
1561 let the help text span full-width, left-aligned. */
1562 .wprf-name-enable_mcp{display:flex;flex-wrap:wrap;align-items:center}
1563 .wprf-name-enable_mcp .wprf-control-label{width:auto!important;flex:0 0 auto!important;margin:0 12px 0 0!important}
1564 .wprf-name-enable_mcp .wprf-control-field{display:contents}
1565 .wprf-name-enable_mcp .wprf-toggle-wrap{order:2}
1566 .wprf-name-enable_mcp .wprf-help{order:3;flex-basis:100%;width:100%;margin:8px 0 0!important}
1567
1568 /* ---- Stat tiles -------------------------------------------------- */
1569 .nx-mcp-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
1570 @media(max-width:960px){.nx-mcp-stats{grid-template-columns:repeat(2,1fr)}}
1571 @media(max-width:600px){.nx-mcp-stats{grid-template-columns:1fr}}
1572 .nx-mcp-stat{border:1px solid #e6e6ec;border-radius:12px;padding:13px 15px;background:#fff;position:relative;overflow:hidden}
1573 .nx-mcp-stat:before{content:"";position:absolute;top:0;bottom:0;inset-inline-start:0;width:3px;background:#6a4bff;opacity:.85}
1574 .nx-mcp-stat-top{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}
1575 .nx-mcp-stat-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#6b7280}
1576 .nx-mcp-stat-ic{width:26px;height:26px;border-radius:8px;background:#f4f2ff;display:flex;align-items:center;justify-content:center;flex:none}
1577 .nx-mcp-stat-ic img{width:16px;height:16px;display:block}
1578 .nx-mcp-stat-row{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
1579 .nx-mcp-stat-value{font-size:26px;line-height:1.1;font-weight:700;color:#1f2330}
1580 .nx-mcp-stat-value.is-sm{font-size:16px;line-height:1.4}
1581 .nx-mcp-stat-note{font-size:11.5px;color:#8a8f9c}
1582
1583
1584 /* ---- Pills ------------------------------------------------------- */
1585 .nx-mcp-pill{font-size:10px;font-weight:700;padding:2px 8px;border-radius:999px;text-transform:uppercase;letter-spacing:.03em;white-space:nowrap}
1586 .nx-mcp-pill-pro{background:#fff1d6;color:#9a6400}
1587 .nx-mcp-pill-oauth{background:#f0eefe;color:#6a4bff}
1588 .nx-mcp-pill-token{background:#e7f1ff;color:#1d4ed8}
1589
1590
1591 /* ---- Connection cards -------------------------------------------- */
1592 .nx-mcp-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
1593 @media(max-width:782px){.nx-mcp-grid{grid-template-columns:1fr}}
1594 .nx-mcp-card{border:1px solid #e6e6ec;border-radius:12px;padding:14px 16px;background:#fff}
1595 .nx-mcp-card-label{display:block;font-weight:700;font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px}
1596 .nx-mcp-copyrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
1597 .nx-mcp-value{background:#f6f7f9;border:1px solid #e6e6ec;border-radius:8px;padding:7px 10px;font-size:12px;flex:1;min-width:0;overflow:auto;white-space:nowrap}
1598 .nx-mcp-copy{cursor:pointer;border:1px solid #d3d4da;background:#fff;border-radius:8px;padding:7px 13px;font-size:12px;font-weight:600;color:#2c3338;transition:background .15s,border-color .15s}
1599 .nx-mcp-copy:hover{background:#f4f2ff;border-color:#c3b8ff;color:#4c31d6}
1600 .nx-mcp-copy-wide{display:block;width:100%;margin-top:12px;text-align:center}
1601 .nx-mcp-hint{margin:8px 0 0;color:#8a8f9c;font-size:12px}
1602
1603 /* ---- Pending (enabled but unsaved) ------------------------------- */
1604
1605 /* ---- Client cards ------------------------------------------------ */
1606 .nx-mcp-clients{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
1607 @media(max-width:960px){.nx-mcp-clients{grid-template-columns:1fr}}
1608 .nx-mcp-client{border:1px solid #e6e6ec;border-radius:12px;padding:14px 16px;background:#fff;display:flex;flex-direction:column;transition:border-color .15s,box-shadow .15s}
1609 .nx-mcp-client:hover{border-color:#c3b8ff;box-shadow:0 6px 18px rgba(106,75,255,.08)}
1610 .nx-mcp-client-name{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
1611 .nx-mcp-client-title{font-weight:700;font-size:13.5px;color:#1f2330}
1612 /* Nothing on this tab is submitted by the settings form: the enable
1613 toggle saves itself and everything else is an ajax button or
1614 read-only text, so a Save button here only invites a click that
1615 does nothing. The control is a single shared quickbuilder
1616 component every other tab still needs, so it is hidden for this
1617 tab rather than removed. Selector mirrors the Entries tab, which
1618 already hides it the same way, and has to out-specify
1619 `#notificationx .wp-react-form... .wprf-submit{display:flex}`. */
1620 #notificationx .nx-admin-wrapper .nx-settings-form-wrapper.tab-mcp .wprf-submit.wprf-control{display:none}
1621 /* Client icons are <img> tags pointing at real SVG files: the card HTML is
1622 kses-filtered, which strips <svg> and rejects data: URIs in src/style. */
1623 .nx-mcp-client-ic{width:20px;height:20px;flex:none;display:inline-block;vertical-align:middle}
1624 .nx-mcp-steps{margin:0;padding-inline-start:18px;color:#50575e;font-size:12.5px;line-height:1.75;flex:1}
1625
1626 /* ---- Connected apps ---------------------------------------------- */
1627 .nx-mcp-apps-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;flex-wrap:wrap}
1628 .nx-mcp-apps-hint{color:#8a8f9c;font-size:12px}
1629 .nx-mcp-btn-sm{padding:5px 12px;font-size:12px}
1630 /* Once moved into the section heading bar, sit flush right on that row. */
1631 .wprf-section-title .nx-mcp-refresh-apps{margin-inline-start:auto}
1632 .nx-mcp-apps-head:empty{display:none;margin:0}
1633 .nx-mcp-apps{display:flex;flex-direction:column;gap:10px}
1634 .nx-mcp-app{display:flex;justify-content:space-between;align-items:center;gap:12px;border:1px solid #e6e6ec;border-radius:10px;padding:11px 14px;background:#fff}
1635 .nx-mcp-app-info{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-width:0}
1636 .nx-mcp-scope{font-size:11px;font-weight:700;padding:2px 9px;border-radius:999px}
1637 .nx-mcp-scope-ro{background:#eef0f3;color:#50575e}
1638 .nx-mcp-scope-rw{background:#d8f7e2;color:#127a35}
1639 .nx-mcp-revoke{cursor:pointer;border:1px solid #e2b5b6;background:#fff;color:#d63638;border-radius:8px;padding:6px 13px;font-size:12px;font-weight:600;flex:none;transition:background .15s,color .15s,border-color .15s}
1640 .nx-mcp-revoke:hover{background:#d63638;color:#fff;border-color:#d63638}
1641 .nx-mcp-empty{color:#8a8f9c;font-style:italic}
1642
1643 /* ---- Health + danger --------------------------------------------- */
1644 .nx-mcp-health{display:flex;flex-direction:column;gap:9px}
1645 .nx-mcp-health-row{display:flex;align-items:center;gap:9px;color:#2c3338;font-size:13px}
1646 .nx-mcp-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
1647 .nx-mcp-dot-good{background:#16a34a}
1648 .nx-mcp-dot-warn{background:#dba617}
1649 .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:#fdf4f4;border-radius:12px;flex-wrap:wrap}
1650 .nx-mcp-danger-text{display:flex;flex-direction:column;gap:2px}
1651 .nx-mcp-danger-text strong{color:#8a1f21}
1652 .nx-mcp-danger-text span{color:#a15b5b;font-size:12px}
1653
1654 /* ---- Buttons ----------------------------------------------------- */
1655 .nx-mcp-actions{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap;align-items:center}
1656 .nx-mcp-btn{cursor:pointer;border-radius:8px;padding:9px 17px;font-size:13px;font-weight:600;border:1px solid transparent;line-height:1.2;text-decoration:none!important;display:inline-flex;align-items:center;justify-content:center;transition:background .15s,border-color .15s,box-shadow .15s}
1657 .nx-mcp-btn[disabled]{opacity:.6;cursor:default}
1658 .nx-mcp-btn-primary{background:#6a4bff;color:#fff!important;box-shadow:0 4px 12px rgba(106,75,255,.25)}
1659 .nx-mcp-btn-primary:hover{background:#583fd6}
1660 /* Kept as an alias: earlier markup used -secondary for the same control. */
1661 .nx-mcp-btn-secondary{background:#6a4bff;color:#fff}
1662 .nx-mcp-btn-secondary:hover{background:#583fd6}
1663 .nx-mcp-btn-ghost{background:#fff;color:#2c3338;border-color:#d3d4da}
1664 .nx-mcp-btn-ghost:hover{background:#f6f7f9;border-color:#c3c4c7}
1665 .nx-mcp-btn-danger{background:#d63638;color:#fff;border-color:#d63638}
1666 .nx-mcp-btn-danger:hover{background:#b32d2e}
1667
1668 /* ---- Focus ------------------------------------------------------- */
1669 /* WP admin sets `a:focus{outline:2px solid transparent}` and leans on a
1670 box-shadow that never lands here, so the hero link had no visible focus
1671 state at all. Every control in the panel gets an explicit brand-colour
1672 ring. Keyboard only —
1673 :focus-visible keeps mouse clicks from drawing it. */
1674 .nx-mcp-copy:focus-visible,.nx-mcp-btn:focus-visible,.nx-mcp-revoke:focus-visible{outline:2px solid #4c31d6;outline-offset:2px;border-radius:8px}
1675 .nx-mcp-learn:focus-visible{outline:2px solid #4c31d6;outline-offset:3px;border-radius:4px}
1676 /* The controls that are anchors, not buttons — the hero link — is additionally zeroed by NotificationX's own
1677 `#notificationx a:focus{outline:0}`, which carries an id and outranks a
1678 class. Same id here so the ring survives; everything else in the panel
1679 is a <button> and never meets that rule. */
1680 #notificationx a.nx-mcp-learn:focus-visible{outline:2px solid #4c31d6;outline-offset:3px;border-radius:4px}
1681
1682 /* ---- Inline result + toast --------------------------------------- */
1683 .nx-mcp-result{display:none;margin-top:12px;padding:11px 14px;border-radius:10px;font-size:12.5px;line-height:1.6;border:1px solid transparent}
1684 .nx-mcp-result.is-shown{display:block}
1685 .nx-mcp-result.is-ok{background:#eefaf1;border-color:#bfe6cb;color:#12652c}
1686 .nx-mcp-result.is-err{background:#fdf1f1;border-color:#f0c4c4;color:#8a1f21}
1687 .nx-mcp-toast{position:fixed;bottom:28px;inset-inline-end:28px;z-index:100001;padding:12px 18px;border-radius:10px;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}
1688 .nx-mcp-toast-in{opacity:1;transform:translateY(0)}
1689 .nx-mcp-toast-success{background:#16a34a}
1690 .nx-mcp-toast-error{background:#d63638}
1691 </style>
1692 <script id="nx-mcp-panel-js">
1693 window.nxMcpData = { urls: <?php echo wp_json_encode( $urls ); ?>, nonce: <?php echo wp_json_encode( $nonce ); ?>, i18n: <?php echo wp_json_encode( $i18n ); ?> };
1694 window.nxMcpToast = function(type, msg){
1695 var t = document.createElement('div');
1696 t.className = 'nx-mcp-toast nx-mcp-toast-' + (type === 'error' ? 'error' : 'success');
1697 t.textContent = msg;
1698 document.body.appendChild(t);
1699 requestAnimationFrame(function(){ t.classList.add('nx-mcp-toast-in'); });
1700 setTimeout(function(){ t.classList.remove('nx-mcp-toast-in'); setTimeout(function(){ t.remove(); }, 320); }, 3600);
1701 };
1702 window.nxMcpCopy = function(btn, text){
1703 var done = function(){ var o = btn.textContent; btn.textContent = '✓ ' + window.nxMcpData.i18n.copied; setTimeout(function(){ btn.textContent = o; }, 1400); };
1704 if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, done); }
1705 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(); }
1706 };
1707 window.nxMcpReveal = function(btn){
1708 var code = btn.parentNode.querySelector('.nx-mcp-token'); if(!code) return;
1709 if (code.dataset.shown === '1'){ code.textContent = '••••••••••••'; code.dataset.shown='0'; btn.textContent='<?php echo esc_js( __( 'Show', 'notificationx' ) ); ?>'; return; }
1710 // The panel may have been rendered before MCP was switched on, in
1711 // which case there was no token to print into it. Fetch it rather
1712 // than revealing an empty box.
1713 nxMcpWithToken(function(token){
1714 code.textContent = token || ''; code.dataset.shown='1';
1715 btn.textContent='<?php echo esc_js( __( 'Hide', 'notificationx' ) ); ?>';
1716 });
1717 };
1718 window.nxMcpCopyToken = function(btn){
1719 var code = btn.parentNode.querySelector('.nx-mcp-token'); if(!code) return;
1720 nxMcpWithToken(function(token){ nxMcpCopy(btn, token || ''); });
1721 };
1722 // Hand the caller the token, fetching it once if the panel does not
1723 // have one yet.
1724 window.nxMcpWithToken = function(done){
1725 var code = document.querySelector('.nx-mcp-token');
1726 var have = code && code.dataset.token;
1727 if (have) { done(code.dataset.token); return; }
1728 nxMcpSyncConnection(function(state){ done(state && state.token ? state.token : ''); });
1729 };
1730 // The config-file clients want a server block, not a bare URL. It is
1731 // assembled here from the token already rendered into the connection
1732 // card, so the page never carries a second copy of the secret.
1733 window.nxMcpCopyConfig = function(btn){
1734 // Same source as the token card's Copy: fetched once if the panel
1735 // was rendered before MCP was switched on.
1736 nxMcpWithToken(function(token){
1737 if (!token){ nxMcpToast('error', window.nxMcpData.i18n.tokenMissing); return; }
1738 var config = {
1739 mcpServers: {
1740 notificationx: {
1741 url: btn.getAttribute('data-url') || '',
1742 headers: { Authorization: 'Bearer ' + token }
1743 }
1744 }
1745 };
1746 nxMcpCopy(btn, JSON.stringify(config, null, 2));
1747 nxMcpToast('success', window.nxMcpData.i18n.configCopied);
1748 });
1749 };
1750 // Write an action's outcome into the panel next to the button that ran
1751 // it. The toast still fires: it covers the case where the button has
1752 // been scrolled out of view, and this covers the case where the reader
1753 // looks back at the panel after the toast has gone.
1754 window.nxMcpShowResult = function(id, ok, message){
1755 var box = document.getElementById(id);
1756 if (!box) return;
1757 box.textContent = message || '';
1758 box.className = 'nx-mcp-result is-shown ' + (ok ? 'is-ok' : 'is-err');
1759 };
1760 window.nxMcpAction = function(btn, action, opts){
1761 opts = opts || {};
1762 if (opts.confirm && !window.confirm(opts.confirm)) return;
1763 var old = btn.textContent; btn.disabled = true; btn.textContent = '…';
1764 fetch(window.nxMcpData.urls[action], {
1765 method:'POST',
1766 headers:{'Content-Type':'application/json','X-WP-Nonce':window.nxMcpData.nonce},
1767 body: JSON.stringify(opts.body || {})
1768 }).then(function(r){ return r.json().catch(function(){ return {}; }); }).then(function(res){
1769 btn.disabled = false; btn.textContent = old;
1770 if (res && res.status === 'error'){
1771 if (opts.result) nxMcpShowResult(opts.result, false, res.message || window.nxMcpData.i18n.genericError);
1772 nxMcpToast('error', res.message || window.nxMcpData.i18n.genericError); return;
1773 }
1774 var msg = opts.success || (res && res.message) || window.nxMcpData.i18n.done;
1775 if (opts.result) nxMcpShowResult(opts.result, true, (res && res.message) || msg);
1776 nxMcpToast('success', msg);
1777 if (opts.reload){ setTimeout(function(){ window.location.reload(); }, 900); }
1778 }).catch(function(){
1779 btn.disabled = false; btn.textContent = old;
1780 if (opts.result) nxMcpShowResult(opts.result, false, window.nxMcpData.i18n.requestFailed);
1781 nxMcpToast('error', window.nxMcpData.i18n.requestFailed);
1782 });
1783 };
1784 window.nxMcpRevoke = function(btn, type, clientId){
1785 nxMcpAction(btn, 'revoke', {
1786 confirm: window.nxMcpData.i18n.revokeConfirm,
1787 body: { type: type, client_id: clientId },
1788 reload: true,
1789 success: window.nxMcpData.i18n.revoked
1790 });
1791 };
1792 // Re-read the connected apps without a full page reload, so a newly
1793 // approved or detached client shows up immediately. Rows are built with
1794 // textContent because a client's name comes from dynamic registration.
1795 window.nxMcpRefreshApps = function(btn){
1796 var wrap = document.getElementById('nx-mcp-apps-wrap');
1797 if (!wrap) return;
1798 var old = btn ? btn.textContent : '';
1799 if (btn){ btn.disabled = true; btn.textContent = '…'; }
1800 fetch(window.nxMcpData.urls.apps, {
1801 method: 'GET',
1802 credentials: 'same-origin',
1803 headers: { 'X-WP-Nonce': window.nxMcpData.nonce }
1804 }).then(function(r){ return r.json(); }).then(function(res){
1805 if (btn){ btn.disabled = false; btn.textContent = old; }
1806 if (!res || res.status !== 'success' || !Array.isArray(res.apps)){
1807 nxMcpToast('error', window.nxMcpData.i18n.refreshFailed); return;
1808 }
1809 // What was on screen before this refresh, so the toast can report
1810 // the actual delta rather than just restating a count.
1811 var prev = [];
1812 wrap.querySelectorAll('.nx-mcp-app').forEach(function(el){
1813 prev.push(el.getAttribute('data-nx-key') || '');
1814 });
1815 var next = res.apps.map(function(a){ return a.type + ':' + (a.client_id || ''); });
1816 var added = next.filter(function(k){ return prev.indexOf(k) === -1; }).length;
1817 var removed = prev.filter(function(k){ return next.indexOf(k) === -1; }).length;
1818 var i18n = window.nxMcpData.i18n;
1819 var toast;
1820 if (!added && !removed) {
1821 toast = next.length ? i18n.upToDate : i18n.noneStill;
1822 } else if (added && !removed) {
1823 toast = added === 1 ? i18n.addedOne : i18n.addedMany.replace('%d', added);
1824 } else if (removed && !added) {
1825 toast = removed === 1 ? i18n.removedOne : i18n.removedMany.replace('%d', removed);
1826 } else {
1827 toast = i18n.changed;
1828 }
1829
1830 while (wrap.firstChild) { wrap.removeChild(wrap.firstChild); }
1831 if (!res.apps.length){
1832 var p = document.createElement('p');
1833 p.className = 'nx-mcp-empty';
1834 p.textContent = i18n.empty;
1835 wrap.appendChild(p);
1836 nxMcpToast('success', toast);
1837 return;
1838 }
1839 var list = document.createElement('div');
1840 list.className = 'nx-mcp-apps';
1841 res.apps.forEach(function(app){
1842 var row = document.createElement('div'); row.className = 'nx-mcp-app';
1843 row.setAttribute('data-nx-key', app.type + ':' + (app.client_id || ''));
1844 var info = document.createElement('div'); info.className = 'nx-mcp-app-info';
1845 var name = document.createElement('strong'); name.textContent = app.name || '';
1846 var scope = document.createElement('span');
1847 scope.className = 'nx-mcp-scope ' + (app.read_only ? 'nx-mcp-scope-ro' : 'nx-mcp-scope-rw');
1848 scope.textContent = app.scope_label || '';
1849 info.appendChild(name); info.appendChild(scope);
1850 var rev = document.createElement('button');
1851 rev.type = 'button'; rev.className = 'nx-mcp-revoke';
1852 rev.textContent = window.nxMcpData.i18n.revoke;
1853 rev.addEventListener('click', function(){ nxMcpRevoke(rev, app.type, app.client_id || ''); });
1854 row.appendChild(info); row.appendChild(rev);
1855 list.appendChild(row);
1856 });
1857 wrap.appendChild(list);
1858 nxMcpToast('success', toast);
1859 }).catch(function(){
1860 if (btn){ btn.disabled = false; btn.textContent = old; }
1861 nxMcpToast('error', window.nxMcpData.i18n.refreshFailed);
1862 });
1863 };
1864 // The section heading ("Connected apps") is rendered by the settings form,
1865 // outside this message field, so the button starts inside the content box.
1866 // Move it onto that heading row once it exists — flex handles the exact
1867 // alignment, so no hard-coded offsets that break at the responsive padding
1868 // change. If this never runs the button simply stays in the box and works.
1869 window.nxMcpPlaceRefresh = function(){
1870 var btns = document.querySelectorAll('.nx-mcp-refresh-apps');
1871 if (!btns.length) return;
1872 var fresh = null, section = null, i;
1873 for (i = 0; i < btns.length; i++){
1874 var sec = btns[i].closest ? btns[i].closest('.wprf-control-section') : null;
1875 if (!sec) continue;
1876 var t = sec.querySelector('.wprf-section-title');
1877 if (!t) continue;
1878 section = sec;
1879 // A button still sitting in the content box is a freshly rendered one.
1880 if (!t.contains(btns[i])) { fresh = btns[i]; break; }
1881 }
1882 if (!section || !fresh) return;
1883 var title = section.querySelector('.wprf-section-title');
1884 if (!title) return;
1885 // Drop any previously moved button first, so a re-render cannot leave two.
1886 var stale = title.querySelectorAll('.nx-mcp-refresh-apps');
1887 for (i = 0; i < stale.length; i++){ stale[i].parentNode.removeChild(stale[i]); }
1888 title.appendChild(fresh);
1889 };
1890 if (window.MutationObserver){
1891 new MutationObserver(function(){ nxMcpPlaceRefresh(); })
1892 .observe(document.body, { childList: true, subtree: true });
1893 }
1894 document.addEventListener('DOMContentLoaded', function(){ nxMcpPlaceRefresh(); });
1895 nxMcpPlaceRefresh();
1896
1897 // Bound by delegation rather than an inline onclick, so the buttons keep
1898 // working even if the panel markup is passed through a sanitiser.
1899 document.addEventListener('click', function(e){
1900 if (!e.target || !e.target.closest) return;
1901 var refresh = e.target.closest('.nx-mcp-refresh-apps');
1902 if (refresh){ e.preventDefault(); nxMcpRefreshApps(refresh); return; }
1903 var config = e.target.closest('.nx-mcp-copy-config');
1904 if (config){ e.preventDefault(); nxMcpCopyConfig(config); }
1905 });
1906
1907 // Paint the badge for a given state.
1908 // Only the label changes, so the status dot inside the badge survives.
1909 window.nxMcpPaintBadge = function(on){
1910 var badge = document.querySelector('.nx-mcp-badge');
1911 if (!badge) return;
1912 var text = badge.querySelector('.nx-mcp-badge-text');
1913 var label = on ? window.nxMcpData.i18n.statusActive : window.nxMcpData.i18n.statusOff;
1914 if (text) { text.textContent = label; } else { badge.textContent = label; }
1915 badge.className = 'nx-mcp-badge nx-mcp-badge-' + (on ? 'active' : 'off');
1916 // The stats row is revealed by the toggle too; keep its status tile in step.
1917 var tile = document.querySelector('.nx-mcp-stat-status');
1918 if (tile) { tile.textContent = label; }
1919 };
1920
1921 // The enable toggle saves itself. The settings form's own Save still
1922 // works, but the toggle gates every panel below it, so leaving it
1923 // unsaved meant the connector URL, token and connection test all
1924 // described a state the server was not in.
1925 var nxMcpEnableInFlight = false;
1926 var nxMcpToggleSync = false;
1927 // The toggle is a controlled React input: setting `checked` on the DOM
1928 // node leaves the settings form holding the old value, and the form's
1929 // Save would later write it back. Click it instead, so React's own
1930 // onChange updates the form state, and skip our handler for that click.
1931 var nxMcpSetToggle = function(input, value){
1932 if (!!input.checked === value) return;
1933 nxMcpToggleSync = true;
1934 try { input.click(); } finally { nxMcpToggleSync = false; }
1935 };
1936 document.addEventListener('change', function(e){
1937 if (!e.target || e.target.name !== 'enable_mcp') return;
1938 if (nxMcpToggleSync) return;
1939 var input = e.target;
1940 var on = !!input.checked;
1941
1942 // Show the intent straight away, then reconcile with the server.
1943 nxMcpPaintBadge(on);
1944
1945 if (nxMcpEnableInFlight) return;
1946 nxMcpEnableInFlight = true;
1947 input.disabled = true;
1948
1949 fetch(window.nxMcpData.urls.enable, {
1950 method: 'POST',
1951 headers: { 'Content-Type':'application/json', 'X-WP-Nonce': window.nxMcpData.nonce },
1952 body: JSON.stringify({ enabled: on })
1953 }).then(function(r){
1954 return r.json().catch(function(){ return {}; }).then(function(j){
1955 if (!r.ok) { throw new Error((j && j.message) || 'http'); }
1956 return j;
1957 });
1958 }).then(function(res){
1959 nxMcpEnableInFlight = false; input.disabled = false;
1960 // Server is the truth: repaint from what it reports.
1961 var saved = !!res.enabled;
1962 nxMcpSetToggle(input, saved);
1963 nxMcpPaintBadge(saved);
1964 if (res.token) { nxMcpSetToken(res.token); }
1965 nxMcpToast('success', saved ? window.nxMcpData.i18n.enabled : window.nxMcpData.i18n.disabled);
1966 }).catch(function(){
1967 nxMcpEnableInFlight = false; input.disabled = false;
1968 // Put the control back where it was so it cannot claim a
1969 // state that was never stored.
1970 nxMcpSetToggle(input, !on);
1971 nxMcpPaintBadge(!on);
1972 nxMcpToast('error', window.nxMcpData.i18n.enableFailed);
1973 });
1974 });
1975
1976 // Fill in the token the panel was rendered without, so Show/Copy and
1977 // the connection test work without a reload.
1978 window.nxMcpSetToken = function(token){
1979 var code = document.querySelector('.nx-mcp-token');
1980 if (!code) return;
1981 code.dataset.token = token;
1982 if (code.dataset.shown === '1') { code.textContent = token; }
1983 };
1984
1985 // Re-read the connection from the server and repaint the panel.
1986 // The panel is server-rendered once; anything that switches MCP on
1987 // afterwards -- the toggle, or the settings form's own Save -- leaves
1988 // the markup describing the old state until this runs.
1989 window.nxMcpSyncConnection = function(done){
1990 fetch(window.nxMcpData.urls.connection, {
1991 headers: { 'X-WP-Nonce': window.nxMcpData.nonce }
1992 }).then(function(r){ return r.json(); }).then(function(state){
1993 if (state && typeof state.enabled !== 'undefined') { nxMcpPaintBadge(!!state.enabled); }
1994 if (state && state.token) { nxMcpSetToken(state.token); }
1995 if (done) { done(state); }
1996 }).catch(function(){ if (done) { done(null); } });
1997 };
1998
1999 // The settings form's Save can switch MCP on without going through
2000 // the toggle handler (a value restored by the browser, or a save
2001 // triggered from another tab). Pick the new state up either way.
2002 document.addEventListener('click', function(e){
2003 var btn = e.target && e.target.closest ? e.target.closest('.wprf-submit-button') : null;
2004 if (!btn) return;
2005 if (!document.querySelector('.nx-mcp-token')) return;
2006 setTimeout(function(){ nxMcpSyncConnection(); }, 1200);
2007 });
2008 </script>
2009 <?php
2010 }
2011
2012 /* --------------------------------------------------------------------- */
2013 /* Helpers */
2014 /* --------------------------------------------------------------------- */
2015
2016 /**
2017 * RFC 9728 protected-resource metadata, served from our own REST namespace.
2018 *
2019 * @return \WP_REST_Response
2020 */
2021 public function rest_protected_resource() {
2022 if ( ! $this->is_enabled() ) {
2023 return $this->discovery_disabled();
2024 }
2025
2026 return new \WP_REST_Response( OAuth::get_instance()->protected_resource_metadata(), 200 );
2027 }
2028
2029 /**
2030 * RFC 8414 authorization-server metadata, served from our own REST namespace.
2031 *
2032 * @return \WP_REST_Response
2033 */
2034 public function rest_authorization_server() {
2035 if ( ! $this->is_enabled() ) {
2036 return $this->discovery_disabled();
2037 }
2038
2039 return new \WP_REST_Response( OAuth::get_instance()->authorization_server_metadata(), 200 );
2040 }
2041
2042 /**
2043 * The response for a discovery request made while MCP is switched off.
2044 * A 404 keeps us indistinguishable from a site that never shipped MCP, so
2045 * a client cannot read our settings state from the discovery surface.
2046 *
2047 * @return \WP_Error
2048 */
2049 protected function discovery_disabled() {
2050 return new \WP_Error(
2051 'rest_no_route',
2052 __( 'No route was found matching the URL and request method.', 'notificationx' ),
2053 array( 'status' => 404 )
2054 );
2055 }
2056
2057 /**
2058 * Whether an OAuth discovery path belongs to this plugin.
2059 *
2060 * The handler runs on `parse_request` at priority 0 and `emit_json()`
2061 * exits, so whatever it answers is final -- nothing later in the request
2062 * gets a say. A prefix match would therefore serve our metadata for
2063 * *any* suffix, including another MCP plugin's
2064 * `.well-known/oauth-protected-resource/<their-plugin>/mcp`, sending
2065 * their clients to our authorization server (RFC 9728 requires the
2066 * resource to match exactly, so their handshake then fails).
2067 *
2068 * Two forms are ours, and only those two:
2069 *
2070 * - the bare document, which our own `WWW-Authenticate` challenge
2071 * advertises (see Server::with_challenge());
2072 * - the RFC 9728 path-suffixed form for our endpoint.
2073 *
2074 * Anything else is declined by returning false, so the request falls
2075 * through to whichever plugin does own it -- deliberately not a 404,
2076 * which would break that neighbour just as effectively.
2077 *
2078 * @param string $path Request path, relative to home and unslashed.
2079 * @param string $doc Discovery document name.
2080 * @return bool
2081 */
2082 protected function owns_discovery_path( $path, $doc ) {
2083 $base = '.well-known/' . $doc;
2084
2085 return $path === $base || $path === $base . '/' . self::ENDPOINT_PATH;
2086 }
2087
2088 /**
2089 * The request path relative to the WordPress home path, without query string.
2090 *
2091 * @return string
2092 */
2093 protected function request_path() {
2094 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- parsed below.
2095 $uri = esc_url_raw( $uri );
2096 $path = wp_parse_url( $uri, PHP_URL_PATH );
2097 if ( ! $path ) {
2098 return '';
2099 }
2100
2101 $home_path = wp_parse_url( home_url(), PHP_URL_PATH );
2102 if ( $home_path && 0 === strpos( $path, $home_path ) ) {
2103 $path = substr( $path, strlen( $home_path ) );
2104 }
2105
2106 return trim( $path, '/' );
2107 }
2108
2109 /**
2110 * Emit an array as a JSON document and stop.
2111 *
2112 * @param array $data Payload.
2113 * @return void
2114 */
2115 protected function emit_json( $data ) {
2116 nocache_headers();
2117 header( 'Content-Type: application/json; charset=utf-8' );
2118 header( 'Access-Control-Allow-Origin: *' );
2119 header( 'Cache-Control: public, max-age=3600' );
2120 echo wp_json_encode( $data );
2121 exit;
2122 }
2123
2124 /**
2125 * Emit a WP_REST_Response (status + headers + JSON body) and stop.
2126 *
2127 * @param \WP_REST_Response $response Response.
2128 * @return void
2129 */
2130 protected function emit_rest_response( $response ) {
2131 $status = $response->get_status();
2132 $headers = $response->get_headers();
2133 $data = $response->get_data();
2134
2135 if ( ! isset( $headers['Content-Type'] ) ) {
2136 header( 'Content-Type: application/json; charset=utf-8' );
2137 }
2138 foreach ( $headers as $key => $value ) {
2139 header( $key . ': ' . $value );
2140 }
2141 // Set the status LAST. Emitting an auth header such as WWW-Authenticate
2142 // after the status resets the code to 401 in this SAPI, so the status
2143 // must be asserted after every other header() call.
2144 status_header( $status );
2145 if ( function_exists( 'http_response_code' ) ) {
2146 http_response_code( $status );
2147 }
2148 if ( 202 === $status || null === $data ) {
2149 exit;
2150 }
2151 echo wp_json_encode( $data );
2152 exit;
2153 }
2154 }
2155