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
← All changes | includes/MCP/Manager.php +902 -119 3.3.1 → 3.3.3 View file →
@@ -26,8 +26,17 @@
26 26 * @method static Manager get_instance( $args = null )
27 27 */
28 28 class Manager {
29 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 +
30 39 use GetInstance;
31 40
32 41 /**
33 42 * Boot the module. Called from the MCP bootstrap only when the runtime is
@@ -44,14 +53,36 @@
44 53 add_action( 'parse_request', array( $this, 'handle_front_requests' ), 0 );
45 54
46 55 // Admin settings tab (pure PHP field schema; no JS rebuild needed).
47 56 add_filter( 'nx_settings_tab', array( $this, 'register_settings_tab' ), 20 );
57 + add_filter( 'nx_protected_settings', array( $this, 'protect_enable_setting' ) );
48 58
49 59 // CSS + JS for the MCP panel (copy / reveal / revoke controls).
50 60 add_action( 'admin_print_footer_scripts', array( $this, 'print_panel_assets' ) );
61 + add_action( 'admin_init', array( $this, 'redirect_hidden_tab' ) );
51 62 }
52 63
53 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 + /**
54 85 * Whether MCP access is switched on.
55 86 *
56 87 * @return bool
57 88 */
@@ -87,8 +118,25 @@
87 118 'permission_callback' => '__return_true',
88 119 ) );
89 120
90 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 + ) );
91 139 register_rest_route( $ns, '/mcp/oauth/register', array(
92 140 'methods' => 'POST',
93 141 'callback' => array( $this, 'rest_oauth_register' ),
94 142 'permission_callback' => '__return_true',
@@ -125,8 +173,24 @@
125 173 'methods' => 'POST',
126 174 'callback' => array( $this, 'rest_self_test' ),
127 175 'permission_callback' => $admin,
128 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 + ) );
129 193 register_rest_route( $ns, '/mcp/apps/revoke', array(
130 194 'methods' => 'POST',
131 195 'callback' => array( $this, 'rest_revoke_app' ),
132 196 'permission_callback' => $admin,
@@ -255,8 +319,13 @@
255 319 *
256 320 * @return \WP_REST_Response
257 321 */
258 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 +
259 328 return new \WP_REST_Response( $this->connection_state(), 200 );
260 329 }
261 330
262 331 /**
@@ -295,13 +364,66 @@
295 364 *
296 365 * @return \WP_REST_Response
297 366 */
298 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 +
299 375 $result = SelfTest::get_instance()->run();
300 376 return new \WP_REST_Response( array( 'status' => $result['ok'] ? 'success' : 'error', 'message' => $result['message'] ) + $result, 200 );
301 377 }
302 378
303 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 + /**
304 426 * Summarise the connection for the admin UI.
305 427 *
306 428 * @return array
307 429 */
@@ -332,18 +454,25 @@
332 454 if ( '' === $path ) {
333 455 return;
334 456 }
335 457
336 - // OAuth discovery (also accept the path-suffixed RFC form).
337 - if ( 0 === strpos( $path, '.well-known/oauth-authorization-server' ) ) {
338 - $this->emit_json( OAuth::get_instance()->authorization_server_metadata() );
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 + }
339 471 }
340 - if ( 0 === strpos( $path, '.well-known/oauth-protected-resource' ) ) {
341 - $this->emit_json( OAuth::get_instance()->protected_resource_metadata() );
342 - }
343 472
344 473 // Pretty MCP endpoint.
345 - if ( 'notificationx/mcp' === $path ) {
474 + if ( self::ENDPOINT_PATH === $path ) {
346 475 $this->handle_pretty_mcp();
347 476 }
348 477
349 478 // OAuth authorize consent page.
@@ -437,8 +566,51 @@
437 566 $this->render_authorize_page( $request );
438 567 }
439 568
440 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 + /**
441 613 * Output the consent form.
442 614 *
443 615 * @param array $request Validated authorize request.
444 616 * @return void
@@ -472,10 +644,10 @@
472 644 }
473 645 $substr = function_exists( 'mb_substr' ) ? 'mb_substr' : 'substr';
474 646 $who_initial = strtoupper( $substr( $who_name, 0, 1 ) );
475 647 $client_initial = strtoupper( $substr( $name, 0, 1 ) );
476 - // Show the connecting app's own mark when we recognise it; otherwise the initial.
477 - $client_is_claude = ( false !== stripos( $name, 'claude' ) );
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'] );
478 650
479 651 // The exact tools this grant unlocks, straight from the ability
480 652 // registry so the list can never drift from what the server exposes.
481 653 Registrar::get_instance()->boot();
@@ -514,10 +686,9 @@
514 686 .apps{display:flex;align-items:flex-start;justify-content:center;gap:8px;margin:4px 0 22px}
515 687 .app{width:132px;text-align:center}
516 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)}
517 689 .tile.client{background:#eef0f6;color:#3a4056;font-size:26px;font-weight:700}
518 - .tile.client.has-mark{background:#fdf1ec}
519 - .tile.client svg{width:38px;height:38px;display:block}
690 + .tile.client svg,.tile.client img{width:38px;height:38px;display:block}
520 691 .tile.nx{background:#fff;border:1px solid var(--line)}
521 692 .tile.nx svg{width:42px;height:42px;display:block}
522 693 .app-name{font-size:14px;font-weight:600;line-height:1.3}
523 694 .app-host{font-size:12px;color:var(--muted);word-break:break-word;margin-top:2px}
@@ -556,11 +727,11 @@
556 727 <body>
557 728 <div class="card">
558 729 <div class="apps">
559 730 <div class="app">
560 - <div class="tile client<?php echo $client_is_claude ? ' has-mark' : ''; ?>">
561 - <?php if ( $client_is_claude ) : ?>
562 - <svg viewBox="0 0 24 24" fill="none" stroke="#d97757" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><line x1="12" y1="3" x2="12" y2="21"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(30 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(60 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(90 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(120 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(150 12 12)"/></svg>
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" />
563 734 <?php else : ?>
564 735 <?php echo esc_html( $client_initial ); ?>
565 736 <?php endif; ?>
566 737 </div>
@@ -662,11 +833,19 @@
662 833 * @param array $tabs Existing tabs.
663 834 * @return array
664 835 */
665 836 public function register_settings_tab( $tabs ) {
666 - // If MCP is on, make sure a pairing token exists so the UI has one to show.
667 - if ( $this->is_enabled() && ! Pairing::get_instance()->is_connected() ) {
668 - Pairing::get_instance()->connect();
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;
669 848 }
670 849
671 850 $tabs['tab-mcp'] = array(
672 851 'id' => 'tab-mcp',
@@ -678,12 +857,38 @@
678 857 return $tabs;
679 858 }
680 859
681 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 + /**
682 879 * Build the MCP settings field schema. The rich panels are server-rendered
683 880 * HTML delivered through quickbuilder `message` fields (html => true); the
684 - * action buttons use quickbuilder `button` fields for the ajax + toast.
881 + * action buttons are plain buttons wired to the globals printed by
882 + * {@see print_panel_assets()}.
685 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 + *
686 891 * @return array
687 892 */
688 893 protected function settings_fields() {
689 894 $enabled_rule = Rules::is( 'enable_mcp', true );
@@ -697,8 +902,9 @@
697 902 'mcp_hero' => array(
698 903 'name' => 'mcp_hero',
699 904 'type' => 'message',
700 905 'html' => true,
906 + 'classes' => 'nx-mcp-field nx-mcp-field-flush',
701 907 'message' => $this->hero_html(),
702 908 ),
703 909 'enable_mcp' => array(
704 910 'name' => 'enable_mcp',
@@ -704,10 +910,18 @@
704 910 'name' => 'enable_mcp',
705 911 'type' => 'toggle',
706 912 'default' => false,
707 913 'label' => __( 'Enable MCP access', 'notificationx' ),
708 - 'help' => __( 'When enabled and saved, approved AI assistants can connect to this site to manage notifications and read analytics.', 'notificationx' ),
914 + 'help' => __( 'When enabled, approved AI assistants can connect to this site to manage notifications and read analytics.', 'notificationx' ),
709 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 + ),
710 924 ),
711 925 ),
712 926
713 927 'mcp_connection_section' => array(
@@ -719,8 +933,9 @@
719 933 'mcp_connection_html' => array(
720 934 'name' => 'mcp_connection_html',
721 935 'type' => 'message',
722 936 'html' => true,
937 + 'classes' => 'nx-mcp-field',
723 938 'message' => $this->connection_html(),
724 939 ),
725 940 ),
726 941 ),
@@ -734,8 +949,9 @@
734 949 'mcp_clients_html' => array(
735 950 'name' => 'mcp_clients_html',
736 951 'type' => 'message',
737 952 'html' => true,
953 + 'classes' => 'nx-mcp-field',
738 954 'message' => $this->clients_html(),
739 955 ),
740 956 ),
741 957 ),
@@ -749,8 +965,9 @@
749 965 'mcp_apps_html' => array(
750 966 'name' => 'mcp_apps_html',
751 967 'type' => 'message',
752 968 'html' => true,
969 + 'classes' => 'nx-mcp-field',
753 970 'message' => $this->connected_apps_html(),
754 971 ),
755 972 ),
756 973 ),
@@ -764,8 +981,9 @@
764 981 'mcp_health_html' => array(
765 982 'name' => 'mcp_health_html',
766 983 'type' => 'message',
767 984 'html' => true,
985 + 'classes' => 'nx-mcp-field',
768 986 'message' => $this->health_html(),
769 987 ),
770 988 ),
771 989 ),
@@ -789,31 +1007,126 @@
789 1007 return array( 'setup', __( 'Setup needed', 'notificationx' ) );
790 1008 }
791 1009
792 1010 /**
793 - * Hero header with the status badge.
1011 + * The abilities currently registered, split the way the panel reads them.
794 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.
795 1077 * @return string
796 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 + */
797 1089 protected function hero_html() {
798 1090 list( $state, $label ) = $this->status();
1091 + $steps = $this->setup_steps();
799 1092 ob_start();
800 1093 ?>
801 - <div class="nx-mcp-hero">
802 - <div class="nx-mcp-hero-icon">&#128268;</div>
803 - <div class="nx-mcp-hero-body">
804 - <h3 class="nx-mcp-hero-title">
805 - <?php esc_html_e( 'MCP Server', 'notificationx' ); ?>
806 - <span class="nx-mcp-badge nx-mcp-badge-<?php echo esc_attr( $state ); ?>"><?php echo esc_html( $label ); ?></span>
807 - </h3>
808 - <p class="nx-mcp-hero-text">
809 - <?php esc_html_e( 'Connect NotificationX to Claude, ChatGPT, Cursor and other AI assistants through a built-in MCP server, so you can manage notifications and read analytics in plain language. It is off by default and only administrators can use it.', 'notificationx' ); ?>
810 - </p>
811 - <a class="nx-mcp-learn" href="<?php echo esc_url( 'https://notificationx.com/docs/mcp-in-notificationx' ); ?>" target="_blank" rel="noopener noreferrer">
812 - <span class="nx-mcp-learn-text"><?php esc_html_e( 'Learn how it works', 'notificationx' ); ?></span>
813 - <span class="nx-mcp-learn-arrow" aria-hidden="true">&rarr;</span>
814 - </a>
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>
815 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>
816 1129 </div>
817 1130 <?php
818 1131 return ob_get_clean();
819 1132 }
@@ -818,15 +1131,115 @@
818 1131 return ob_get_clean();
819 1132 }
820 1133
821 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 + /**
822 1228 * Connector URL + token cards with copy/reveal controls.
823 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 + *
824 1234 * @return string
825 1235 */
826 1236 protected function connection_html() {
827 - $url = $this->connector_url();
828 - $token = Pairing::get_instance()->site_token();
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();
829 1242 ob_start();
830 1243 ?>
831 1244 <div class="nx-mcp-grid">
832 1245 <div class="nx-mcp-card">
@@ -839,19 +1252,21 @@
839 1252 </div>
840 1253 <div class="nx-mcp-card">
841 1254 <span class="nx-mcp-card-label"><?php esc_html_e( 'Connection token', 'notificationx' ); ?></span>
842 1255 <div class="nx-mcp-copyrow">
843 - <code class="nx-mcp-value nx-mcp-token" data-token="<?php echo esc_attr( $token ); ?>">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</code>
1256 + <code class="nx-mcp-value nx-mcp-token" data-token="">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</code>
844 1257 <button type="button" class="nx-mcp-copy" onclick="nxMcpReveal(this)"><?php esc_html_e( 'Show', 'notificationx' ); ?></button>
845 - <button type="button" class="nx-mcp-copy" onclick="nxMcpCopy(this,'<?php echo esc_js( $token ); ?>')"><?php esc_html_e( 'Copy', 'notificationx' ); ?></button>
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>
846 1260 </div>
847 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>
848 1262 </div>
849 1263 </div>
850 1264 <div class="nx-mcp-actions">
851 - <button type="button" class="nx-mcp-btn nx-mcp-btn-secondary" onclick="nxMcpAction(this,'test',{success:'<?php echo esc_js( __( 'Connection test passed — the MCP server is reachable and exposing its tools.', 'notificationx' ) ); ?>'})"><?php esc_html_e( 'Test connection', 'notificationx' ); ?></button>
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>
852 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>
853 1267 </div>
1268 + <div class="nx-mcp-result" id="nx-mcp-testresult"></div>
854 1269 <?php
855 1270 return ob_get_clean();
856 1271 }
857 1272
@@ -857,38 +1272,59 @@
857 1272
858 1273 /**
859 1274 * Per-client setup cards.
860 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 + *
861 1282 * @return string
862 1283 */
863 1284 protected function clients_html() {
864 - $url = esc_html( $this->connector_url() );
1285 + $url = $this->connector_url();
865 1286 ob_start();
866 1287 ?>
867 1288 <div class="nx-mcp-clients">
868 1289 <div class="nx-mcp-client">
869 - <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/claude.svg' ); ?>" /> <?php esc_html_e( 'Claude', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'OAuth', 'notificationx' ); ?></span></div>
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>
870 1295 <ol class="nx-mcp-steps">
871 1296 <li><?php esc_html_e( 'In Claude, add a custom connector.', 'notificationx' ); ?></li>
872 1297 <li><?php esc_html_e( 'Paste the Connector URL above.', 'notificationx' ); ?></li>
873 1298 <li><?php esc_html_e( 'Approve the connection when prompted — you sign in here, no token needed.', 'notificationx' ); ?></li>
874 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>
875 1301 </div>
876 1302 <div class="nx-mcp-client">
877 - <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/chatgpt.svg' ); ?>" /> <?php esc_html_e( 'ChatGPT', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'Token', 'notificationx' ); ?></span></div>
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>
878 1308 <ol class="nx-mcp-steps">
879 1309 <li><?php esc_html_e( 'Settings → Connectors → Add a custom connector.', 'notificationx' ); ?></li>
880 - <li><?php /* translators: %s: connector URL */ printf( esc_html__( 'Use the URL %s.', 'notificationx' ), '<code>' . $url . '</code>' ); ?></li>
1310 + <li><?php esc_html_e( 'Use the Connector URL above.', 'notificationx' ); ?></li>
881 1311 <li><?php esc_html_e( 'Provide the connection token as a Bearer credential.', 'notificationx' ); ?></li>
882 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>
883 1314 </div>
884 1315 <div class="nx-mcp-client">
885 - <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/cursor.svg' ); ?>" /> <?php esc_html_e( 'Cursor &amp; others', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'Token', 'notificationx' ); ?></span></div>
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>
886 1321 <ol class="nx-mcp-steps">
887 - <li><?php esc_html_e( 'Add an MCP server with the Connector URL above.', 'notificationx' ); ?></li>
888 - <li><?php esc_html_e( 'Set the Authorization header to: Bearer <token>.', 'notificationx' ); ?></li>
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>
889 1324 <li><?php esc_html_e( 'Confirm the install when the client asks.', 'notificationx' ); ?></li>
890 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>
891 1327 </div>
892 1328 </div>
893 1329 <?php
894 1330 return ob_get_clean();
@@ -894,13 +1330,8 @@
894 1330 return ob_get_clean();
895 1331 }
896 1332
897 1333 /**
898 - * The list of currently connected AI apps (pairing token + OAuth clients).
899 - *
900 - * @return string
901 - */
902 - /**
903 1334 * The currently connected apps (pairing token + active OAuth clients). Shared
904 1335 * by the rendered panel and the /mcp/apps endpoint so the two cannot drift.
905 1336 *
906 1337 * @return array[] Each: type, client_id, name, read_only.
@@ -1017,14 +1448,16 @@
1017 1448 // after a client-side navigation. Otherwise the panel shows unstyled until
1018 1449 // a manual reload.
1019 1450 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check.
1020 1451 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
1021 - if ( ! is_admin() || 0 !== strpos( $page, 'nx-' ) ) {
1452 + if ( ! is_admin() || 0 !== strpos( $page, 'nx-' ) || ! current_user_can( 'manage_options' ) ) {
1022 1453 return;
1023 1454 }
1024 1455 $nonce = wp_create_nonce( 'wp_rest' );
1025 1456 $urls = array(
1026 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' ) ),
1027 1460 'rotate' => esc_url_raw( rest_url( 'notificationx/v1/mcp/rotate' ) ),
1028 1461 'disconnect' => esc_url_raw( rest_url( 'notificationx/v1/mcp/disconnect' ) ),
1029 1462 'revoke' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps/revoke' ) ),
1030 1463 'apps' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps' ) ),
@@ -1033,8 +1466,17 @@
1033 1466 'revoke' => __( 'Revoke', 'notificationx' ),
1034 1467 'empty' => __( 'No AI clients are connected yet.', 'notificationx' ),
1035 1468 'refreshFailed' => __( 'Could not refresh the connected apps.', 'notificationx' ),
1036 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' ),
1037 1479 // Refresh outcomes: say what actually changed, not just a count.
1038 1480 'noneStill' => __( 'No apps connected yet.', 'notificationx' ),
1039 1481 'upToDate' => __( 'Up to date — nothing changed.', 'notificationx' ),
1040 1482 'addedOne' => __( '1 new app connected.', 'notificationx' ),
@@ -1043,90 +1485,209 @@
1043 1485 'removedOne' => __( '1 app disconnected.', 'notificationx' ),
1044 1486 /* translators: %d: number of disconnected apps. */
1045 1487 'removedMany' => __( '%d apps disconnected.', 'notificationx' ),
1046 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' ),
1047 1494 );
1048 1495 ?>
1049 1496 <style id="nx-mcp-panel-css">
1050 - .nx-mcp-hero{display:flex;gap:14px;align-items:flex-start}
1051 - .nx-mcp-hero-icon{font-size:26px;line-height:1}
1052 - .nx-mcp-hero-title{margin:0 0 6px;font-size:18px;display:flex;align-items:center;gap:10px}
1053 - .nx-mcp-hero-text{margin:0;color:#50575e;max-width:640px}
1054 - .nx-mcp-learn{display:inline-flex;align-items:center;gap:5px;margin-top:10px;color:#6a4bff;font-size:13px;font-weight:600}
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}
1055 1525 /* The message-field CSS (#notificationx .wprf-message p a) underlines the
1056 1526 whole anchor at rest, which draws a line under the arrow too. Override
1057 1527 it in every state (!important beats that #id rule) and underline only
1058 1528 the text span on hover. */
1059 - .nx-mcp-learn,.nx-mcp-learn:link,.nx-mcp-learn:visited,.nx-mcp-learn:hover,.nx-mcp-learn:focus,.nx-mcp-learn:active{text-decoration:none!important}
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}
1060 1530 .nx-mcp-learn .nx-mcp-learn-text{text-decoration:none}
1061 1531 .nx-mcp-learn:hover .nx-mcp-learn-text{text-decoration:underline}
1062 1532 .nx-mcp-learn-arrow{display:inline-block;transition:transform .2s}
1063 1533 .nx-mcp-learn:hover .nx-mcp-learn-arrow{transform:translateX(3px)}
1064 - .nx-mcp-badge{font-size:11px;font-weight:600;padding:2px 10px;border-radius:999px;text-transform:uppercase;letter-spacing:.02em}
1065 - .nx-mcp-badge-off{background:#e2e4e7;color:#50575e}
1066 - .nx-mcp-badge-active{background:#e5f6ea;color:#1a7f37}
1067 - .nx-mcp-badge-setup{background:#fcf3e3;color:#996800}
1068 - /* Enable toggle: keep label + switch on one row (no fixed 200px label
1069 - column gap) and let the help text span full-width, left-aligned. */
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. */
1070 1562 .wprf-name-enable_mcp{display:flex;flex-wrap:wrap;align-items:center}
1071 1563 .wprf-name-enable_mcp .wprf-control-label{width:auto!important;flex:0 0 auto!important;margin:0 12px 0 0!important}
1072 1564 .wprf-name-enable_mcp .wprf-control-field{display:contents}
1073 1565 .wprf-name-enable_mcp .wprf-toggle-wrap{order:2}
1074 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 -------------------------------------------- */
1075 1592 .nx-mcp-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
1076 1593 @media(max-width:782px){.nx-mcp-grid{grid-template-columns:1fr}}
1077 - .nx-mcp-card{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff}
1078 - .nx-mcp-card-label{display:block;font-weight:600;font-size:12px;color:#50575e;text-transform:uppercase;letter-spacing:.03em;margin-bottom:8px}
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}
1079 1596 .nx-mcp-copyrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
1080 - .nx-mcp-value{background:#f6f7f7;border:1px solid #e0e0e0;border-radius:6px;padding:6px 10px;font-size:12px;flex:1;min-width:0;overflow:auto;white-space:nowrap}
1081 - .nx-mcp-copy{cursor:pointer;border:1px solid #c3c4c7;background:#f6f7f7;border-radius:6px;padding:6px 12px;font-size:12px;font-weight:600;color:#2c3338}
1082 - .nx-mcp-copy:hover{background:#eef0f1}
1083 - .nx-mcp-hint{margin:8px 0 0;color:#787c82;font-size:12px}
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 ------------------------------------------------ */
1084 1606 .nx-mcp-clients{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
1085 1607 @media(max-width:960px){.nx-mcp-clients{grid-template-columns:1fr}}
1086 - .nx-mcp-client{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff}
1087 - .nx-mcp-client-name{font-weight:600;display:flex;align-items:center;gap:8px;margin-bottom:8px}
1088 - .nx-mcp-tag{font-size:10px;font-weight:600;background:#f0eefe;color:#6a4bff;padding:2px 8px;border-radius:999px;text-transform:uppercase}
1089 - .nx-mcp-steps{margin:0;padding-left:18px;color:#50575e;font-size:13px;line-height:1.7}
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 ---------------------------------------------- */
1090 1627 .nx-mcp-apps-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;flex-wrap:wrap}
1091 - .nx-mcp-apps-hint{color:#787c82;font-size:12px}
1628 + .nx-mcp-apps-hint{color:#8a8f9c;font-size:12px}
1092 1629 .nx-mcp-btn-sm{padding:5px 12px;font-size:12px}
1093 1630 /* Once moved into the section heading bar, sit flush right on that row. */
1094 - .wprf-section-title .nx-mcp-refresh-apps{margin-left:auto}
1631 + .wprf-section-title .nx-mcp-refresh-apps{margin-inline-start:auto}
1095 1632 .nx-mcp-apps-head:empty{display:none;margin:0}
1096 1633 .nx-mcp-apps{display:flex;flex-direction:column;gap:10px}
1097 - .nx-mcp-app{display:flex;justify-content:space-between;align-items:center;border:1px solid #e0e0e0;border-radius:8px;padding:10px 14px;background:#fff}
1098 - .nx-mcp-app-info{display:flex;align-items:center;gap:10px}
1099 - .nx-mcp-scope{font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px}
1100 - .nx-mcp-scope-ro{background:#eef0f1;color:#50575e}
1101 - .nx-mcp-scope-rw{background:#e5f6ea;color:#1a7f37}
1102 - .nx-mcp-revoke{cursor:pointer;border:1px solid #d63638;background:#fff;color:#d63638;border-radius:6px;padding:5px 12px;font-size:12px;font-weight:600}
1103 - .nx-mcp-revoke:hover{background:#d63638;color:#fff}
1104 - .nx-mcp-empty{color:#787c82;font-style:italic}
1105 - .nx-mcp-health{display:flex;flex-direction:column;gap:8px}
1106 - .nx-mcp-health-row{display:flex;align-items:center;gap:8px;color:#2c3338;font-size:13px}
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}
1107 1646 .nx-mcp-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
1108 - .nx-mcp-dot-good{background:#1a7f37}
1647 + .nx-mcp-dot-good{background:#16a34a}
1109 1648 .nx-mcp-dot-warn{background:#dba617}
1110 - /* Client icons are <img> tags pointing at real SVG files: the card HTML is
1111 - kses-filtered, which strips <svg> and rejects data: URIs in src/style. */
1112 - .nx-mcp-client-ic{width:20px;height:20px;flex:none;display:inline-block;vertical-align:middle}
1113 - .nx-mcp-actions{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap}
1114 - .nx-mcp-btn{cursor:pointer;border-radius:6px;padding:8px 16px;font-size:13px;font-weight:600;border:1px solid transparent;line-height:1.2}
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}
1115 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. */
1116 1661 .nx-mcp-btn-secondary{background:#6a4bff;color:#fff}
1117 1662 .nx-mcp-btn-secondary:hover{background:#583fd6}
1118 - .nx-mcp-btn-ghost{background:#fff;color:#2c3338;border-color:#c3c4c7}
1119 - .nx-mcp-btn-ghost:hover{background:#f6f7f7}
1663 + .nx-mcp-btn-ghost{background:#fff;color:#2c3338;border-color:#d3d4da}
1664 + .nx-mcp-btn-ghost:hover{background:#f6f7f9;border-color:#c3c4c7}
1120 1665 .nx-mcp-btn-danger{background:#d63638;color:#fff;border-color:#d63638}
1121 1666 .nx-mcp-btn-danger:hover{background:#b32d2e}
1122 - .nx-mcp-danger{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-top:16px;padding:14px 16px;border:1px solid #f0c4c4;background:#fcf0f0;border-radius:10px;flex-wrap:wrap}
1123 - .nx-mcp-danger-text{display:flex;flex-direction:column;gap:2px}
1124 - .nx-mcp-danger-text strong{color:#8a1f21}
1125 - .nx-mcp-danger-text span{color:#a15b5b;font-size:12px}
1126 - .nx-mcp-toast{position:fixed;bottom:28px;right:28px;z-index:100001;padding:12px 18px;border-radius:8px;color:#fff;font-size:13px;font-weight:600;box-shadow:0 8px 28px rgba(0,0,0,.2);opacity:0;transform:translateY(12px);transition:opacity .28s,transform .28s;max-width:380px}
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}
1127 1688 .nx-mcp-toast-in{opacity:1;transform:translateY(0)}
1128 - .nx-mcp-toast-success{background:#1a7f37}
1689 + .nx-mcp-toast-success{background:#16a34a}
1129 1690 .nx-mcp-toast-error{background:#d63638}
1130 1691 </style>
1131 1692 <script id="nx-mcp-panel-js">
1132 1693 window.nxMcpData = { urls: <?php echo wp_json_encode( $urls ); ?>, nonce: <?php echo wp_json_encode( $nonce ); ?>, i18n: <?php echo wp_json_encode( $i18n ); ?> };
@@ -1138,17 +1699,65 @@
1138 1699 requestAnimationFrame(function(){ t.classList.add('nx-mcp-toast-in'); });
1139 1700 setTimeout(function(){ t.classList.remove('nx-mcp-toast-in'); setTimeout(function(){ t.remove(); }, 320); }, 3600);
1140 1701 };
1141 1702 window.nxMcpCopy = function(btn, text){
1142 - var done = function(){ var o = btn.textContent; btn.textContent = '✓'; setTimeout(function(){ btn.textContent = o; }, 1200); };
1703 + var done = function(){ var o = btn.textContent; btn.textContent = '✓ ' + window.nxMcpData.i18n.copied; setTimeout(function(){ btn.textContent = o; }, 1400); };
1143 1704 if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, done); }
1144 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(); }
1145 1706 };
1146 1707 window.nxMcpReveal = function(btn){
1147 1708 var code = btn.parentNode.querySelector('.nx-mcp-token'); if(!code) return;
1148 - if (code.dataset.shown === '1'){ code.textContent = '••••••••••••'; code.dataset.shown='0'; btn.textContent='Show'; }
1149 - else { code.textContent = code.dataset.token || ''; code.dataset.shown='1'; btn.textContent='Hide'; }
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 + });
1150 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 + };
1151 1760 window.nxMcpAction = function(btn, action, opts){
1152 1761 opts = opts || {};
1153 1762 if (opts.confirm && !window.confirm(opts.confirm)) return;
1154 1763 var old = btn.textContent; btn.disabled = true; btn.textContent = '…';
@@ -1157,19 +1766,28 @@
1157 1766 headers:{'Content-Type':'application/json','X-WP-Nonce':window.nxMcpData.nonce},
1158 1767 body: JSON.stringify(opts.body || {})
1159 1768 }).then(function(r){ return r.json().catch(function(){ return {}; }); }).then(function(res){
1160 1769 btn.disabled = false; btn.textContent = old;
1161 - if (res && res.status === 'error'){ nxMcpToast('error', res.message || 'Something went wrong.'); return; }
1162 - nxMcpToast('success', opts.success || (res && res.message) || 'Done.');
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);
1163 1777 if (opts.reload){ setTimeout(function(){ window.location.reload(); }, 900); }
1164 - }).catch(function(){ btn.disabled = false; btn.textContent = old; nxMcpToast('error', 'Request failed.'); });
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 + });
1165 1783 };
1166 1784 window.nxMcpRevoke = function(btn, type, clientId){
1167 1785 nxMcpAction(btn, 'revoke', {
1168 - confirm: 'Revoke this connection? The client will need to reconnect.',
1786 + confirm: window.nxMcpData.i18n.revokeConfirm,
1169 1787 body: { type: type, client_id: clientId },
1170 1788 reload: true,
1171 - success: 'Connection revoked.'
1789 + success: window.nxMcpData.i18n.revoked
1172 1790 });
1173 1791 };
1174 1792 // Re-read the connected apps without a full page reload, so a newly
1175 1793 // approved or detached client shows up immediately. Rows are built with
@@ -1177,9 +1795,9 @@
1177 1795 window.nxMcpRefreshApps = function(btn){
1178 1796 var wrap = document.getElementById('nx-mcp-apps-wrap');
1179 1797 if (!wrap) return;
1180 1798 var old = btn ? btn.textContent : '';
1181 - if (btn){ btn.disabled = true; btn.textContent = '\u2026'; }
1799 + if (btn){ btn.disabled = true; btn.textContent = '…'; }
1182 1800 fetch(window.nxMcpData.urls.apps, {
1183 1801 method: 'GET',
1184 1802 credentials: 'same-origin',
1185 1803 headers: { 'X-WP-Nonce': window.nxMcpData.nonce }
@@ -1275,26 +1893,119 @@
1275 1893 }
1276 1894 document.addEventListener('DOMContentLoaded', function(){ nxMcpPlaceRefresh(); });
1277 1895 nxMcpPlaceRefresh();
1278 1896
1279 - // Bound by delegation rather than an inline onclick, so the button keeps
1897 + // Bound by delegation rather than an inline onclick, so the buttons keep
1280 1898 // working even if the panel markup is passed through a sanitiser.
1281 1899 document.addEventListener('click', function(e){
1282 - var btn = e.target && e.target.closest ? e.target.closest('.nx-mcp-refresh-apps') : null;
1283 - if (!btn) return;
1284 - e.preventDefault();
1285 - nxMcpRefreshApps(btn);
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); }
1286 1905 });
1287 1906
1288 - // Keep the status badge in sync with the enable toggle, live.
1289 - document.addEventListener('change', function(e){
1290 - if (!e.target || e.target.name !== 'enable_mcp') return;
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){
1291 1910 var badge = document.querySelector('.nx-mcp-badge');
1292 1911 if (!badge) return;
1293 - var on = !!e.target.checked;
1294 - badge.textContent = on ? '<?php echo esc_js( __( 'Active', 'notificationx' ) ); ?>' : '<?php echo esc_js( __( 'Off', 'notificationx' ) ); ?>';
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; }
1295 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 + });
1296 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 + });
1297 2008 </script>
1298 2009 <?php
1299 2010 }
1300 2011
@@ -1300,8 +2011,80 @@
1300 2011
1301 2012 /* --------------------------------------------------------------------- */
1302 2013 /* Helpers */
1303 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 + }
1304 2087
1305 2088 /**
1306 2089 * The request path relative to the WordPress home path, without query string.
1307 2090 *