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 +1087 -111 3.3.0 → 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,16 +173,66 @@
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,
133 197 ) );
198 + register_rest_route( $ns, '/mcp/apps', array(
199 + 'methods' => 'GET',
200 + 'callback' => array( $this, 'rest_list_apps' ),
201 + 'permission_callback' => $admin,
202 + ) );
134 203 }
135 204
136 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 + /**
137 235 * Revoke a single connected app (pairing token or one OAuth client).
138 236 *
139 237 * @param \WP_REST_Request $request Request.
140 238 * @return \WP_REST_Response
@@ -221,8 +319,13 @@
221 319 *
222 320 * @return \WP_REST_Response
223 321 */
224 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 +
225 328 return new \WP_REST_Response( $this->connection_state(), 200 );
226 329 }
227 330
228 331 /**
@@ -261,13 +364,66 @@
261 364 *
262 365 * @return \WP_REST_Response
263 366 */
264 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 +
265 375 $result = SelfTest::get_instance()->run();
266 376 return new \WP_REST_Response( array( 'status' => $result['ok'] ? 'success' : 'error', 'message' => $result['message'] ) + $result, 200 );
267 377 }
268 378
269 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 + /**
270 426 * Summarise the connection for the admin UI.
271 427 *
272 428 * @return array
273 429 */
@@ -298,18 +454,25 @@
298 454 if ( '' === $path ) {
299 455 return;
300 456 }
301 457
302 - // OAuth discovery (also accept the path-suffixed RFC form).
303 - if ( 0 === strpos( $path, '.well-known/oauth-authorization-server' ) ) {
304 - $this->emit_json( OAuth::get_instance()->authorization_server_metadata() );
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 + }
305 471 }
306 - if ( 0 === strpos( $path, '.well-known/oauth-protected-resource' ) ) {
307 - $this->emit_json( OAuth::get_instance()->protected_resource_metadata() );
308 - }
309 472
310 473 // Pretty MCP endpoint.
311 - if ( 'notificationx/mcp' === $path ) {
474 + if ( self::ENDPOINT_PATH === $path ) {
312 475 $this->handle_pretty_mcp();
313 476 }
314 477
315 478 // OAuth authorize consent page.
@@ -403,8 +566,51 @@
403 566 $this->render_authorize_page( $request );
404 567 }
405 568
406 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 + /**
407 613 * Output the consent form.
408 614 *
409 615 * @param array $request Validated authorize request.
410 616 * @return void
@@ -438,10 +644,10 @@
438 644 }
439 645 $substr = function_exists( 'mb_substr' ) ? 'mb_substr' : 'substr';
440 646 $who_initial = strtoupper( $substr( $who_name, 0, 1 ) );
441 647 $client_initial = strtoupper( $substr( $name, 0, 1 ) );
442 - // Show the connecting app's own mark when we recognise it; otherwise the initial.
443 - $client_is_claude = ( false !== stripos( $name, 'claude' ) );
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'] );
444 650
445 651 // The exact tools this grant unlocks, straight from the ability
446 652 // registry so the list can never drift from what the server exposes.
447 653 Registrar::get_instance()->boot();
@@ -480,10 +686,9 @@
480 686 .apps{display:flex;align-items:flex-start;justify-content:center;gap:8px;margin:4px 0 22px}
481 687 .app{width:132px;text-align:center}
482 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)}
483 689 .tile.client{background:#eef0f6;color:#3a4056;font-size:26px;font-weight:700}
484 - .tile.client.has-mark{background:#fdf1ec}
485 - .tile.client svg{width:38px;height:38px;display:block}
690 + .tile.client svg,.tile.client img{width:38px;height:38px;display:block}
486 691 .tile.nx{background:#fff;border:1px solid var(--line)}
487 692 .tile.nx svg{width:42px;height:42px;display:block}
488 693 .app-name{font-size:14px;font-weight:600;line-height:1.3}
489 694 .app-host{font-size:12px;color:var(--muted);word-break:break-word;margin-top:2px}
@@ -522,11 +727,11 @@
522 727 <body>
523 728 <div class="card">
524 729 <div class="apps">
525 730 <div class="app">
526 - <div class="tile client<?php echo $client_is_claude ? ' has-mark' : ''; ?>">
527 - <?php if ( $client_is_claude ) : ?>
528 - <svg viewBox="0 0 24 24" fill="none" stroke="#d97757" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><line x1="12" y1="3" x2="12" y2="21"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(30 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(60 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(90 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(120 12 12)"/><line x1="12" y1="3" x2="12" y2="21" transform="rotate(150 12 12)"/></svg>
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" />
529 734 <?php else : ?>
530 735 <?php echo esc_html( $client_initial ); ?>
531 736 <?php endif; ?>
532 737 </div>
@@ -628,11 +833,19 @@
628 833 * @param array $tabs Existing tabs.
629 834 * @return array
630 835 */
631 836 public function register_settings_tab( $tabs ) {
632 - // If MCP is on, make sure a pairing token exists so the UI has one to show.
633 - if ( $this->is_enabled() && ! Pairing::get_instance()->is_connected() ) {
634 - Pairing::get_instance()->connect();
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;
635 848 }
636 849
637 850 $tabs['tab-mcp'] = array(
638 851 'id' => 'tab-mcp',
@@ -644,12 +857,38 @@
644 857 return $tabs;
645 858 }
646 859
647 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 + /**
648 879 * Build the MCP settings field schema. The rich panels are server-rendered
649 880 * HTML delivered through quickbuilder `message` fields (html => true); the
650 - * 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()}.
651 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 + *
652 891 * @return array
653 892 */
654 893 protected function settings_fields() {
655 894 $enabled_rule = Rules::is( 'enable_mcp', true );
@@ -663,8 +902,9 @@
663 902 'mcp_hero' => array(
664 903 'name' => 'mcp_hero',
665 904 'type' => 'message',
666 905 'html' => true,
906 + 'classes' => 'nx-mcp-field nx-mcp-field-flush',
667 907 'message' => $this->hero_html(),
668 908 ),
669 909 'enable_mcp' => array(
670 910 'name' => 'enable_mcp',
@@ -670,10 +910,18 @@
670 910 'name' => 'enable_mcp',
671 911 'type' => 'toggle',
672 912 'default' => false,
673 913 'label' => __( 'Enable MCP access', 'notificationx' ),
674 - 'help' => __( 'When enabled and saved, approved AI assistants can connect to this site to manage notifications and read analytics.', 'notificationx' ),
914 + 'help' => __( 'When enabled, approved AI assistants can connect to this site to manage notifications and read analytics.', 'notificationx' ),
675 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 + ),
676 924 ),
677 925 ),
678 926
679 927 'mcp_connection_section' => array(
@@ -685,8 +933,9 @@
685 933 'mcp_connection_html' => array(
686 934 'name' => 'mcp_connection_html',
687 935 'type' => 'message',
688 936 'html' => true,
937 + 'classes' => 'nx-mcp-field',
689 938 'message' => $this->connection_html(),
690 939 ),
691 940 ),
692 941 ),
@@ -700,8 +949,9 @@
700 949 'mcp_clients_html' => array(
701 950 'name' => 'mcp_clients_html',
702 951 'type' => 'message',
703 952 'html' => true,
953 + 'classes' => 'nx-mcp-field',
704 954 'message' => $this->clients_html(),
705 955 ),
706 956 ),
707 957 ),
@@ -715,8 +965,9 @@
715 965 'mcp_apps_html' => array(
716 966 'name' => 'mcp_apps_html',
717 967 'type' => 'message',
718 968 'html' => true,
969 + 'classes' => 'nx-mcp-field',
719 970 'message' => $this->connected_apps_html(),
720 971 ),
721 972 ),
722 973 ),
@@ -730,8 +981,9 @@
730 981 'mcp_health_html' => array(
731 982 'name' => 'mcp_health_html',
732 983 'type' => 'message',
733 984 'html' => true,
985 + 'classes' => 'nx-mcp-field',
734 986 'message' => $this->health_html(),
735 987 ),
736 988 ),
737 989 ),
@@ -755,31 +1007,126 @@
755 1007 return array( 'setup', __( 'Setup needed', 'notificationx' ) );
756 1008 }
757 1009
758 1010 /**
759 - * Hero header with the status badge.
1011 + * The abilities currently registered, split the way the panel reads them.
760 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.
761 1077 * @return string
762 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 + */
763 1089 protected function hero_html() {
764 1090 list( $state, $label ) = $this->status();
1091 + $steps = $this->setup_steps();
765 1092 ob_start();
766 1093 ?>
767 - <div class="nx-mcp-hero">
768 - <div class="nx-mcp-hero-icon">&#128268;</div>
769 - <div class="nx-mcp-hero-body">
770 - <h3 class="nx-mcp-hero-title">
771 - <?php esc_html_e( 'MCP Server', 'notificationx' ); ?>
772 - <span class="nx-mcp-badge nx-mcp-badge-<?php echo esc_attr( $state ); ?>"><?php echo esc_html( $label ); ?></span>
773 - </h3>
774 - <p class="nx-mcp-hero-text">
775 - <?php esc_html_e( 'Connect NotificationX to Claude, ChatGPT, Cursor and other AI assistants through a built-in MCP server, so you can manage notifications and read analytics in plain language. It is off by default and only administrators can use it.', 'notificationx' ); ?>
776 - </p>
777 - <a class="nx-mcp-learn" href="<?php echo esc_url( 'https://notificationx.com/docs/mcp-in-notificationx' ); ?>" target="_blank" rel="noopener noreferrer">
778 - <span class="nx-mcp-learn-text"><?php esc_html_e( 'Learn how it works', 'notificationx' ); ?></span>
779 - <span class="nx-mcp-learn-arrow" aria-hidden="true">&rarr;</span>
780 - </a>
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>
781 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>
782 1129 </div>
783 1130 <?php
784 1131 return ob_get_clean();
785 1132 }
@@ -784,15 +1131,115 @@
784 1131 return ob_get_clean();
785 1132 }
786 1133
787 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 + /**
788 1228 * Connector URL + token cards with copy/reveal controls.
789 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 + *
790 1234 * @return string
791 1235 */
792 1236 protected function connection_html() {
793 - $url = $this->connector_url();
794 - $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();
795 1242 ob_start();
796 1243 ?>
797 1244 <div class="nx-mcp-grid">
798 1245 <div class="nx-mcp-card">
@@ -805,19 +1252,21 @@
805 1252 </div>
806 1253 <div class="nx-mcp-card">
807 1254 <span class="nx-mcp-card-label"><?php esc_html_e( 'Connection token', 'notificationx' ); ?></span>
808 1255 <div class="nx-mcp-copyrow">
809 - <code class="nx-mcp-value nx-mcp-token" data-token="<?php echo esc_attr( $token ); ?>">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</code>
1256 + <code class="nx-mcp-value nx-mcp-token" data-token="">&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</code>
810 1257 <button type="button" class="nx-mcp-copy" onclick="nxMcpReveal(this)"><?php esc_html_e( 'Show', 'notificationx' ); ?></button>
811 - <button type="button" class="nx-mcp-copy" onclick="nxMcpCopy(this,'<?php echo esc_js( $token ); ?>')"><?php esc_html_e( 'Copy', 'notificationx' ); ?></button>
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>
812 1260 </div>
813 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>
814 1262 </div>
815 1263 </div>
816 1264 <div class="nx-mcp-actions">
817 - <button type="button" class="nx-mcp-btn nx-mcp-btn-secondary" onclick="nxMcpAction(this,'test',{success:'<?php echo esc_js( __( 'Connection test passed — the MCP server is reachable and exposing its tools.', 'notificationx' ) ); ?>'})"><?php esc_html_e( 'Test connection', 'notificationx' ); ?></button>
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>
818 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>
819 1267 </div>
1268 + <div class="nx-mcp-result" id="nx-mcp-testresult"></div>
820 1269 <?php
821 1270 return ob_get_clean();
822 1271 }
823 1272
@@ -823,38 +1272,59 @@
823 1272
824 1273 /**
825 1274 * Per-client setup cards.
826 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 + *
827 1282 * @return string
828 1283 */
829 1284 protected function clients_html() {
830 - $url = esc_html( $this->connector_url() );
1285 + $url = $this->connector_url();
831 1286 ob_start();
832 1287 ?>
833 1288 <div class="nx-mcp-clients">
834 1289 <div class="nx-mcp-client">
835 - <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/claude.svg' ); ?>" /> <?php esc_html_e( 'Claude', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'OAuth', 'notificationx' ); ?></span></div>
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>
836 1295 <ol class="nx-mcp-steps">
837 1296 <li><?php esc_html_e( 'In Claude, add a custom connector.', 'notificationx' ); ?></li>
838 1297 <li><?php esc_html_e( 'Paste the Connector URL above.', 'notificationx' ); ?></li>
839 1298 <li><?php esc_html_e( 'Approve the connection when prompted — you sign in here, no token needed.', 'notificationx' ); ?></li>
840 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>
841 1301 </div>
842 1302 <div class="nx-mcp-client">
843 - <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/chatgpt.svg' ); ?>" /> <?php esc_html_e( 'ChatGPT', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'Token', 'notificationx' ); ?></span></div>
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>
844 1308 <ol class="nx-mcp-steps">
845 1309 <li><?php esc_html_e( 'Settings → Connectors → Add a custom connector.', 'notificationx' ); ?></li>
846 - <li><?php /* translators: %s: connector URL */ printf( esc_html__( 'Use the URL %s.', 'notificationx' ), '<code>' . $url . '</code>' ); ?></li>
1310 + <li><?php esc_html_e( 'Use the Connector URL above.', 'notificationx' ); ?></li>
847 1311 <li><?php esc_html_e( 'Provide the connection token as a Bearer credential.', 'notificationx' ); ?></li>
848 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>
849 1314 </div>
850 1315 <div class="nx-mcp-client">
851 - <div class="nx-mcp-client-name"><img class="nx-mcp-client-ic" width="20" height="20" alt="" src="<?php echo esc_url( NOTIFICATIONX_ADMIN_URL . 'images/mcp/cursor.svg' ); ?>" /> <?php esc_html_e( 'Cursor &amp; others', 'notificationx' ); ?><span class="nx-mcp-tag"><?php esc_html_e( 'Token', 'notificationx' ); ?></span></div>
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>
852 1321 <ol class="nx-mcp-steps">
853 - <li><?php esc_html_e( 'Add an MCP server with the Connector URL above.', 'notificationx' ); ?></li>
854 - <li><?php esc_html_e( 'Set the Authorization header to: Bearer <token>.', 'notificationx' ); ?></li>
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>
855 1324 <li><?php esc_html_e( 'Confirm the install when the client asks.', 'notificationx' ); ?></li>
856 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>
857 1327 </div>
858 1328 </div>
859 1329 <?php
860 1330 return ob_get_clean();
@@ -860,13 +1330,14 @@
860 1330 return ob_get_clean();
861 1331 }
862 1332
863 1333 /**
864 - * The list of currently connected AI apps (pairing token + OAuth clients).
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.
865 1336 *
866 - * @return string
1337 + * @return array[] Each: type, client_id, name, read_only.
867 1338 */
868 - protected function connected_apps_html() {
1339 + protected function get_connected_apps() {
869 1340 $apps = array();
870 1341
871 1342 // Only list the token connection once a client has actually used it —
872 1343 // the token existing on its own is not a "connected app".
@@ -888,9 +1359,22 @@
888 1359 'read_only' => ! empty( $client['read_only'] ),
889 1360 );
890 1361 }
891 1362
1363 + return $apps;
1364 + }
1365 +
1366 + protected function connected_apps_html() {
1367 + $apps = $this->get_connected_apps();
1368 +
892 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
893 1377 if ( empty( $apps ) ) {
894 1378 echo '<p class="nx-mcp-empty">' . esc_html__( 'No AI clients are connected yet.', 'notificationx' ) . '</p>';
895 1379 } else {
896 1380 echo '<div class="nx-mcp-apps">';
@@ -897,9 +1381,9 @@
897 1381 foreach ( $apps as $app ) {
898 1382 $scope_class = $app['read_only'] ? 'nx-mcp-scope-ro' : 'nx-mcp-scope-rw';
899 1383 $scope_label = $app['read_only'] ? __( 'Read-only', 'notificationx' ) : __( 'Read & write', 'notificationx' );
900 1384 ?>
901 - <div class="nx-mcp-app">
1385 + <div class="nx-mcp-app" data-nx-key="<?php echo esc_attr( $app['type'] . ':' . $app['client_id'] ); ?>">
902 1386 <div class="nx-mcp-app-info">
903 1387 <strong><?php echo esc_html( $app['name'] ); ?></strong>
904 1388 <span class="nx-mcp-scope <?php echo esc_attr( $scope_class ); ?>"><?php echo esc_html( $scope_label ); ?></span>
905 1389 </div>
@@ -908,8 +1392,11 @@
908 1392 <?php
909 1393 }
910 1394 echo '</div>';
911 1395 }
1396 + ?>
1397 + </div>
1398 + <?php
912 1399 return ob_get_clean();
913 1400 }
914 1401
915 1402 /**
@@ -961,97 +1448,250 @@
961 1448 // after a client-side navigation. Otherwise the panel shows unstyled until
962 1449 // a manual reload.
963 1450 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check.
964 1451 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
965 - if ( ! is_admin() || 0 !== strpos( $page, 'nx-' ) ) {
1452 + if ( ! is_admin() || 0 !== strpos( $page, 'nx-' ) || ! current_user_can( 'manage_options' ) ) {
966 1453 return;
967 1454 }
968 1455 $nonce = wp_create_nonce( 'wp_rest' );
969 1456 $urls = array(
970 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' ) ),
971 1460 'rotate' => esc_url_raw( rest_url( 'notificationx/v1/mcp/rotate' ) ),
972 1461 'disconnect' => esc_url_raw( rest_url( 'notificationx/v1/mcp/disconnect' ) ),
973 1462 'revoke' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps/revoke' ) ),
1463 + 'apps' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps' ) ),
974 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 + );
975 1495 ?>
976 1496 <style id="nx-mcp-panel-css">
977 - .nx-mcp-hero{display:flex;gap:14px;align-items:flex-start}
978 - .nx-mcp-hero-icon{font-size:26px;line-height:1}
979 - .nx-mcp-hero-title{margin:0 0 6px;font-size:18px;display:flex;align-items:center;gap:10px}
980 - .nx-mcp-hero-text{margin:0;color:#50575e;max-width:640px}
981 - .nx-mcp-learn{display:inline-flex;align-items:center;gap:5px;margin-top:10px;color:#6a4bff;font-size:13px;font-weight:600}
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}
982 1525 /* The message-field CSS (#notificationx .wprf-message p a) underlines the
983 1526 whole anchor at rest, which draws a line under the arrow too. Override
984 1527 it in every state (!important beats that #id rule) and underline only
985 1528 the text span on hover. */
986 - .nx-mcp-learn,.nx-mcp-learn:link,.nx-mcp-learn:visited,.nx-mcp-learn:hover,.nx-mcp-learn:focus,.nx-mcp-learn:active{text-decoration:none!important}
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}
987 1530 .nx-mcp-learn .nx-mcp-learn-text{text-decoration:none}
988 1531 .nx-mcp-learn:hover .nx-mcp-learn-text{text-decoration:underline}
989 1532 .nx-mcp-learn-arrow{display:inline-block;transition:transform .2s}
990 1533 .nx-mcp-learn:hover .nx-mcp-learn-arrow{transform:translateX(3px)}
991 - .nx-mcp-badge{font-size:11px;font-weight:600;padding:2px 10px;border-radius:999px;text-transform:uppercase;letter-spacing:.02em}
992 - .nx-mcp-badge-off{background:#e2e4e7;color:#50575e}
993 - .nx-mcp-badge-active{background:#e5f6ea;color:#1a7f37}
994 - .nx-mcp-badge-setup{background:#fcf3e3;color:#996800}
995 - /* Enable toggle: keep label + switch on one row (no fixed 200px label
996 - column gap) and let the help text span full-width, left-aligned. */
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. */
997 1562 .wprf-name-enable_mcp{display:flex;flex-wrap:wrap;align-items:center}
998 1563 .wprf-name-enable_mcp .wprf-control-label{width:auto!important;flex:0 0 auto!important;margin:0 12px 0 0!important}
999 1564 .wprf-name-enable_mcp .wprf-control-field{display:contents}
1000 1565 .wprf-name-enable_mcp .wprf-toggle-wrap{order:2}
1001 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 -------------------------------------------- */
1002 1592 .nx-mcp-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
1003 1593 @media(max-width:782px){.nx-mcp-grid{grid-template-columns:1fr}}
1004 - .nx-mcp-card{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff}
1005 - .nx-mcp-card-label{display:block;font-weight:600;font-size:12px;color:#50575e;text-transform:uppercase;letter-spacing:.03em;margin-bottom:8px}
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}
1006 1596 .nx-mcp-copyrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
1007 - .nx-mcp-value{background:#f6f7f7;border:1px solid #e0e0e0;border-radius:6px;padding:6px 10px;font-size:12px;flex:1;min-width:0;overflow:auto;white-space:nowrap}
1008 - .nx-mcp-copy{cursor:pointer;border:1px solid #c3c4c7;background:#f6f7f7;border-radius:6px;padding:6px 12px;font-size:12px;font-weight:600;color:#2c3338}
1009 - .nx-mcp-copy:hover{background:#eef0f1}
1010 - .nx-mcp-hint{margin:8px 0 0;color:#787c82;font-size:12px}
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 ------------------------------------------------ */
1011 1606 .nx-mcp-clients{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
1012 1607 @media(max-width:960px){.nx-mcp-clients{grid-template-columns:1fr}}
1013 - .nx-mcp-client{border:1px solid #e0e0e0;border-radius:10px;padding:14px 16px;background:#fff}
1014 - .nx-mcp-client-name{font-weight:600;display:flex;align-items:center;gap:8px;margin-bottom:8px}
1015 - .nx-mcp-tag{font-size:10px;font-weight:600;background:#f0eefe;color:#6a4bff;padding:2px 8px;border-radius:999px;text-transform:uppercase}
1016 - .nx-mcp-steps{margin:0;padding-left:18px;color:#50575e;font-size:13px;line-height:1.7}
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}
1017 1633 .nx-mcp-apps{display:flex;flex-direction:column;gap:10px}
1018 - .nx-mcp-app{display:flex;justify-content:space-between;align-items:center;border:1px solid #e0e0e0;border-radius:8px;padding:10px 14px;background:#fff}
1019 - .nx-mcp-app-info{display:flex;align-items:center;gap:10px}
1020 - .nx-mcp-scope{font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px}
1021 - .nx-mcp-scope-ro{background:#eef0f1;color:#50575e}
1022 - .nx-mcp-scope-rw{background:#e5f6ea;color:#1a7f37}
1023 - .nx-mcp-revoke{cursor:pointer;border:1px solid #d63638;background:#fff;color:#d63638;border-radius:6px;padding:5px 12px;font-size:12px;font-weight:600}
1024 - .nx-mcp-revoke:hover{background:#d63638;color:#fff}
1025 - .nx-mcp-empty{color:#787c82;font-style:italic}
1026 - .nx-mcp-health{display:flex;flex-direction:column;gap:8px}
1027 - .nx-mcp-health-row{display:flex;align-items:center;gap:8px;color:#2c3338;font-size:13px}
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}
1028 1646 .nx-mcp-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none}
1029 - .nx-mcp-dot-good{background:#1a7f37}
1647 + .nx-mcp-dot-good{background:#16a34a}
1030 1648 .nx-mcp-dot-warn{background:#dba617}
1031 - /* Client icons are <img> tags pointing at real SVG files: the card HTML is
1032 - kses-filtered, which strips <svg> and rejects data: URIs in src/style. */
1033 - .nx-mcp-client-ic{width:20px;height:20px;flex:none;display:inline-block;vertical-align:middle}
1034 - .nx-mcp-actions{display:flex;gap:10px;margin-top:16px;flex-wrap:wrap}
1035 - .nx-mcp-btn{cursor:pointer;border-radius:6px;padding:8px 16px;font-size:13px;font-weight:600;border:1px solid transparent;line-height:1.2}
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}
1036 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. */
1037 1661 .nx-mcp-btn-secondary{background:#6a4bff;color:#fff}
1038 1662 .nx-mcp-btn-secondary:hover{background:#583fd6}
1039 - .nx-mcp-btn-ghost{background:#fff;color:#2c3338;border-color:#c3c4c7}
1040 - .nx-mcp-btn-ghost:hover{background:#f6f7f7}
1663 + .nx-mcp-btn-ghost{background:#fff;color:#2c3338;border-color:#d3d4da}
1664 + .nx-mcp-btn-ghost:hover{background:#f6f7f9;border-color:#c3c4c7}
1041 1665 .nx-mcp-btn-danger{background:#d63638;color:#fff;border-color:#d63638}
1042 1666 .nx-mcp-btn-danger:hover{background:#b32d2e}
1043 - .nx-mcp-danger{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-top:16px;padding:14px 16px;border:1px solid #f0c4c4;background:#fcf0f0;border-radius:10px;flex-wrap:wrap}
1044 - .nx-mcp-danger-text{display:flex;flex-direction:column;gap:2px}
1045 - .nx-mcp-danger-text strong{color:#8a1f21}
1046 - .nx-mcp-danger-text span{color:#a15b5b;font-size:12px}
1047 - .nx-mcp-toast{position:fixed;bottom:28px;right:28px;z-index:100001;padding:12px 18px;border-radius:8px;color:#fff;font-size:13px;font-weight:600;box-shadow:0 8px 28px rgba(0,0,0,.2);opacity:0;transform:translateY(12px);transition:opacity .28s,transform .28s;max-width:380px}
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}
1048 1688 .nx-mcp-toast-in{opacity:1;transform:translateY(0)}
1049 - .nx-mcp-toast-success{background:#1a7f37}
1689 + .nx-mcp-toast-success{background:#16a34a}
1050 1690 .nx-mcp-toast-error{background:#d63638}
1051 1691 </style>
1052 1692 <script id="nx-mcp-panel-js">
1053 - window.nxMcpData = { urls: <?php echo wp_json_encode( $urls ); ?>, nonce: <?php echo wp_json_encode( $nonce ); ?> };
1693 + window.nxMcpData = { urls: <?php echo wp_json_encode( $urls ); ?>, nonce: <?php echo wp_json_encode( $nonce ); ?>, i18n: <?php echo wp_json_encode( $i18n ); ?> };
1054 1694 window.nxMcpToast = function(type, msg){
1055 1695 var t = document.createElement('div');
1056 1696 t.className = 'nx-mcp-toast nx-mcp-toast-' + (type === 'error' ? 'error' : 'success');
1057 1697 t.textContent = msg;
@@ -1059,17 +1699,65 @@
1059 1699 requestAnimationFrame(function(){ t.classList.add('nx-mcp-toast-in'); });
1060 1700 setTimeout(function(){ t.classList.remove('nx-mcp-toast-in'); setTimeout(function(){ t.remove(); }, 320); }, 3600);
1061 1701 };
1062 1702 window.nxMcpCopy = function(btn, text){
1063 - 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); };
1064 1704 if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, done); }
1065 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(); }
1066 1706 };
1067 1707 window.nxMcpReveal = function(btn){
1068 1708 var code = btn.parentNode.querySelector('.nx-mcp-token'); if(!code) return;
1069 - if (code.dataset.shown === '1'){ code.textContent = '••••••••••••'; code.dataset.shown='0'; btn.textContent='Show'; }
1070 - else { code.textContent = code.dataset.token || ''; code.dataset.shown='1'; btn.textContent='Hide'; }
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 + });
1071 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 + };
1072 1760 window.nxMcpAction = function(btn, action, opts){
1073 1761 opts = opts || {};
1074 1762 if (opts.confirm && !window.confirm(opts.confirm)) return;
1075 1763 var old = btn.textContent; btn.disabled = true; btn.textContent = '…';
@@ -1078,30 +1766,246 @@
1078 1766 headers:{'Content-Type':'application/json','X-WP-Nonce':window.nxMcpData.nonce},
1079 1767 body: JSON.stringify(opts.body || {})
1080 1768 }).then(function(r){ return r.json().catch(function(){ return {}; }); }).then(function(res){
1081 1769 btn.disabled = false; btn.textContent = old;
1082 - if (res && res.status === 'error'){ nxMcpToast('error', res.message || 'Something went wrong.'); return; }
1083 - nxMcpToast('success', opts.success || (res && res.message) || 'Done.');
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);
1084 1777 if (opts.reload){ setTimeout(function(){ window.location.reload(); }, 900); }
1085 - }).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 + });
1086 1783 };
1087 1784 window.nxMcpRevoke = function(btn, type, clientId){
1088 1785 nxMcpAction(btn, 'revoke', {
1089 - confirm: 'Revoke this connection? The client will need to reconnect.',
1786 + confirm: window.nxMcpData.i18n.revokeConfirm,
1090 1787 body: { type: type, client_id: clientId },
1091 1788 reload: true,
1092 - success: 'Connection revoked.'
1789 + success: window.nxMcpData.i18n.revoked
1093 1790 });
1094 1791 };
1095 - // Keep the status badge in sync with the enable toggle, live.
1096 - document.addEventListener('change', function(e){
1097 - if (!e.target || e.target.name !== 'enable_mcp') return;
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){
1098 1910 var badge = document.querySelector('.nx-mcp-badge');
1099 1911 if (!badge) return;
1100 - var on = !!e.target.checked;
1101 - badge.textContent = on ? '<?php echo esc_js( __( 'Active', 'notificationx' ) ); ?>' : '<?php echo esc_js( __( 'Off', 'notificationx' ) ); ?>';
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; }
1102 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 + });
1103 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 + });
1104 2008 </script>
1105 2009 <?php
1106 2010 }
1107 2011
@@ -1107,8 +2011,80 @@
1107 2011
1108 2012 /* --------------------------------------------------------------------- */
1109 2013 /* Helpers */
1110 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 + }
1111 2087
1112 2088 /**
1113 2089 * The request path relative to the WordPress home path, without query string.
1114 2090 *