boot(); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_action( 'parse_request', array( $this, 'handle_front_requests' ), 0 ); // Admin settings tab (pure PHP field schema; no JS rebuild needed). add_filter( 'nx_settings_tab', array( $this, 'register_settings_tab' ), 20 ); // CSS + JS for the MCP panel (copy / reveal / revoke controls). add_action( 'admin_print_footer_scripts', array( $this, 'print_panel_assets' ) ); } /** * Whether MCP access is switched on. * * @return bool */ public function is_enabled() { return (bool) Settings::get_instance()->get( 'settings.enable_mcp' ); } /** * The site's MCP connector URL. * * @return string */ public function connector_url() { return home_url( '/notificationx/mcp' ); } /* --------------------------------------------------------------------- */ /* REST routes */ /* --------------------------------------------------------------------- */ /** * Register the transport, OAuth and management routes. * * @return void */ public function register_routes() { $ns = 'notificationx/v1'; // MCP transport — auth happens inside the handler. register_rest_route( $ns, '/mcp', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_mcp' ), 'permission_callback' => '__return_true', ) ); // OAuth: dynamic client registration + token endpoint (public). register_rest_route( $ns, '/mcp/oauth/register', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_oauth_register' ), 'permission_callback' => '__return_true', ) ); register_rest_route( $ns, '/mcp/oauth/token', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_oauth_token' ), 'permission_callback' => '__return_true', ) ); // Management (admin only). $admin = array( $this, 'admin_permission' ); register_rest_route( $ns, '/mcp/connection', array( 'methods' => 'GET', 'callback' => array( $this, 'rest_connection' ), 'permission_callback' => $admin, ) ); register_rest_route( $ns, '/mcp/connect', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_connect' ), 'permission_callback' => $admin, ) ); register_rest_route( $ns, '/mcp/rotate', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_rotate' ), 'permission_callback' => $admin, ) ); register_rest_route( $ns, '/mcp/disconnect', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_disconnect' ), 'permission_callback' => $admin, ) ); register_rest_route( $ns, '/mcp/self-test', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_self_test' ), 'permission_callback' => $admin, ) ); register_rest_route( $ns, '/mcp/apps/revoke', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_revoke_app' ), 'permission_callback' => $admin, ) ); register_rest_route( $ns, '/mcp/apps', array( 'methods' => 'GET', 'callback' => array( $this, 'rest_list_apps' ), 'permission_callback' => $admin, ) ); } /** * List the currently connected apps as JSON, so the Connected apps panel can * refresh itself without a full page reload (an app may have been approved or * detached since the page was rendered). * * @return \WP_REST_Response */ public function rest_list_apps() { $apps = array(); foreach ( $this->get_connected_apps() as $app ) { $apps[] = array( 'type' => $app['type'], 'client_id' => $app['client_id'], 'name' => $app['name'], 'read_only' => (bool) $app['read_only'], 'scope_label' => $app['read_only'] ? __( 'Read-only', 'notificationx' ) : __( 'Read & write', 'notificationx' ), ); } return new \WP_REST_Response( array( 'status' => 'success', 'count' => count( $apps ), 'apps' => $apps, ), 200 ); } /** * Revoke a single connected app (pairing token or one OAuth client). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function rest_revoke_app( $request ) { $params = $request->get_json_params() ?: $request->get_body_params(); $type = isset( $params['type'] ) ? sanitize_text_field( $params['type'] ) : ''; if ( 'pairing' === $type ) { Pairing::get_instance()->disconnect(); } elseif ( 'oauth' === $type && ! empty( $params['client_id'] ) ) { OAuth::get_instance()->revoke_client( sanitize_text_field( $params['client_id'] ) ); } else { return new \WP_REST_Response( array( 'status' => 'error', 'message' => __( 'Nothing to revoke.', 'notificationx' ) ), 400 ); } return new \WP_REST_Response( array( 'status' => 'success' ), 200 ); } /** * Management permission: administrators only. * * @return bool */ public function admin_permission() { return current_user_can( 'manage_options' ); } /** * MCP transport handler (REST). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function rest_mcp( $request ) { return Server::get_instance()->handle( $request ); } /** * OAuth dynamic client registration handler. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rest_oauth_register( $request ) { if ( ! $this->is_enabled() ) { return new \WP_REST_Response( array( 'error' => 'mcp_disabled' ), 403 ); } $result = OAuth::get_instance()->register_client( $request->get_json_params() ?: array() ); if ( is_wp_error( $result ) ) { return new \WP_REST_Response( array( 'error' => $result->get_error_code(), 'error_description' => $result->get_error_message() ), 400 ); } return new \WP_REST_Response( $result, 201 ); } /** * OAuth token handler. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function rest_oauth_token( $request ) { if ( ! $this->is_enabled() ) { return new \WP_REST_Response( array( 'error' => 'mcp_disabled' ), 403 ); } // Token requests are form-encoded per OAuth; fall back to JSON. $params = $request->get_body_params(); if ( empty( $params ) ) { $params = $request->get_json_params() ?: array(); } $result = OAuth::get_instance()->handle_token_request( $params ); if ( is_wp_error( $result ) ) { $resp = new \WP_REST_Response( array( 'error' => $result->get_error_code(), 'error_description' => $result->get_error_message() ), 400 ); } else { $resp = new \WP_REST_Response( $result, 200 ); } $resp->header( 'Cache-Control', 'no-store' ); $resp->header( 'Pragma', 'no-cache' ); return $resp; } /** * Connection status for the admin UI. * * @return \WP_REST_Response */ public function rest_connection() { return new \WP_REST_Response( $this->connection_state(), 200 ); } /** * Enable a pairing connection. * * @return \WP_REST_Response */ public function rest_connect() { Pairing::get_instance()->connect(); return new \WP_REST_Response( array( 'status' => 'success' ) + $this->connection_state(), 200 ); } /** * Rotate the pairing token. * * @return \WP_REST_Response */ public function rest_rotate() { Pairing::get_instance()->rotate(); return new \WP_REST_Response( array( 'status' => 'success' ) + $this->connection_state(), 200 ); } /** * Disconnect: drop the pairing token and revoke all OAuth grants. * * @return \WP_REST_Response */ public function rest_disconnect() { Pairing::get_instance()->disconnect(); OAuth::get_instance()->revoke_all(); return new \WP_REST_Response( array( 'status' => 'success' ), 200 ); } /** * Run the loopback self-test. * * @return \WP_REST_Response */ public function rest_self_test() { $result = SelfTest::get_instance()->run(); return new \WP_REST_Response( array( 'status' => $result['ok'] ? 'success' : 'error', 'message' => $result['message'] ) + $result, 200 ); } /** * Summarise the connection for the admin UI. * * @return array */ protected function connection_state() { $pairing = Pairing::get_instance(); return array( 'enabled' => $this->is_enabled(), 'connected' => $pairing->is_connected(), 'connector_url' => $this->connector_url(), 'token' => $pairing->site_token(), ); } /* --------------------------------------------------------------------- */ /* Front-end requests: pretty endpoint, discovery, authorize page */ /* --------------------------------------------------------------------- */ /** * Intercept the MCP pretty endpoint, OAuth discovery docs and the * authorize page from the front controller. Path-based so it works under * any permalink structure without rewrite flushes. * * @param \WP $wp WordPress environment. * @return void */ public function handle_front_requests( $wp ) { $path = $this->request_path(); if ( '' === $path ) { return; } // OAuth discovery (also accept the path-suffixed RFC form). if ( 0 === strpos( $path, '.well-known/oauth-authorization-server' ) ) { $this->emit_json( OAuth::get_instance()->authorization_server_metadata() ); } if ( 0 === strpos( $path, '.well-known/oauth-protected-resource' ) ) { $this->emit_json( OAuth::get_instance()->protected_resource_metadata() ); } // Pretty MCP endpoint. if ( 'notificationx/mcp' === $path ) { $this->handle_pretty_mcp(); } // OAuth authorize consent page. if ( 'notificationx/authorize' === $path ) { $this->handle_authorize(); } } /** * Handle the pretty MCP endpoint by delegating to the JSON-RPC server. * * @return void */ protected function handle_pretty_mcp() { // Only POST carries a JSON-RPC body; a GET is treated as a probe so // clients discovering the endpoint still get a challenge. $request = new \WP_REST_Request( 'POST', '/notificationx/v1/mcp' ); $auth = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- header validated downstream. if ( $auth ) { $request->set_header( 'authorization', $auth ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- raw JSON-RPC body, parsed/validated by the server. $request->set_body( file_get_contents( 'php://input' ) ); $response = Server::get_instance()->handle( $request ); $this->emit_rest_response( $response ); } /** * Render / process the OAuth authorize consent page. * * @return void */ protected function handle_authorize() { if ( ! $this->is_enabled() ) { status_header( 404 ); exit; } // Require a logged-in administrator; bounce through wp-login if needed. if ( ! is_user_logged_in() ) { $current = ( is_ssl() ? 'https://' : 'http://' ) . sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ) ) . sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) ); wp_safe_redirect( wp_login_url( $current ) ); exit; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to authorize an MCP connection.', 'notificationx' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- these are OAuth request params echoed back into a nonce-protected consent form; no state change on GET. $params = wp_unslash( $_GET ); $request = OAuth::get_instance()->validate_authorize_request( $params ); if ( is_wp_error( $request ) ) { wp_die( esc_html( $request->get_error_message() ) ); } $is_post = ( 'POST' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) ) ) ); // Deny on POST (nonce-checked): bounce back to the client with the // standard OAuth error so it can end the flow cleanly instead of the // user landing on a dead browser tab. if ( $is_post && isset( $_POST['nx_mcp_deny'] ) ) { check_admin_referer( 'nx_mcp_authorize' ); $redirect = add_query_arg( array( 'error' => 'access_denied', 'error_description' => rawurlencode( 'The user denied the authorization request.' ), 'state' => rawurlencode( $request['state'] ), ), $request['redirect_uri'] ); wp_redirect( $redirect ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- redirect_uri is validated against the registered client allow-list. exit; } // Approve on POST (nonce-checked). if ( $is_post && isset( $_POST['nx_mcp_authorize'] ) ) { check_admin_referer( 'nx_mcp_authorize' ); $code = OAuth::get_instance()->issue_code( $request, get_current_user_id() ); $redirect = add_query_arg( array( 'code' => rawurlencode( $code ), 'state' => rawurlencode( $request['state'] ), ), $request['redirect_uri'] ); wp_redirect( $redirect ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- redirect_uri is validated against the registered client allow-list. exit; } $this->render_authorize_page( $request ); } /** * Output the consent form. * * @param array $request Validated authorize request. * @return void */ protected function render_authorize_page( $request ) { $store = get_option( OAuth::OPTION, array() ); $client = isset( $store['clients'][ $request['client_id'] ] ) ? $store['clients'][ $request['client_id'] ] : array(); $name = ! empty( $client['client_name'] ) ? $client['client_name'] : $request['client_id']; $scope = $request['scope']; // What the granted scope actually permits, in plain language. $read_only = OAuth::get_instance()->scope_is_read_only( $scope ); // The two ends of the connection: the client app and this site. $client_host = (string) wp_parse_url( $request['redirect_uri'], PHP_URL_HOST ); $site_name = get_bloginfo( 'name' ); $site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); // Who is about to approve — everything the connection does is recorded // as this user. $user = wp_get_current_user(); $who_name = $user->display_name ? $user->display_name : $user->user_login; $roles = (array) $user->roles; $role_key = $roles ? (string) reset( $roles ) : ''; $role_lbl = ''; if ( $role_key ) { $wp_roles = wp_roles(); if ( isset( $wp_roles->roles[ $role_key ]['name'] ) ) { $role_lbl = translate_user_role( $wp_roles->roles[ $role_key ]['name'] ); } } $substr = function_exists( 'mb_substr' ) ? 'mb_substr' : 'substr'; $who_initial = strtoupper( $substr( $who_name, 0, 1 ) ); $client_initial = strtoupper( $substr( $name, 0, 1 ) ); // Show the connecting app's own mark when we recognise it; otherwise the initial. $client_is_claude = ( false !== stripos( $name, 'claude' ) ); // The exact tools this grant unlocks, straight from the ability // registry so the list can never drift from what the server exposes. Registrar::get_instance()->boot(); $granted = array(); foreach ( Registrar::get_instance()->get_all() as $ability ) { if ( $read_only && $ability->is_write() ) { continue; } $granted[] = $ability; } $cap_label = $read_only ? __( 'Read only', 'notificationx' ) : __( 'Read & write', 'notificationx' ); $cap_text = $read_only ? __( 'It can read your notifications, entries and analytics. It cannot create, change or delete anything.', 'notificationx' ) : __( 'It acts as you: anything it creates, edits or deletes is recorded under your account.', 'notificationx' ); // NotificationX brand mark (assets/admin/images/nx-icon.svg), inlined so // the consent page never depends on a second asset request. $nx_mark = ''; nocache_headers(); header( 'Content-Type: text/html; charset=utf-8' ); ?> > <?php esc_html_e( 'Authorize MCP connection', 'notificationx' ); ?>
NotificationX

' . esc_html( $name ) . '', '' . esc_html( $site_name ? $site_name : $site_host ) . '' ); ?>

' . esc_html( $who_name ) . '', $role_lbl ? ' · ' . esc_html( $role_lbl ) : '' ); ?>
is_enabled() && ! Pairing::get_instance()->is_connected() ) { Pairing::get_instance()->connect(); } $tabs['tab-mcp'] = array( 'id' => 'tab-mcp', 'label' => __( 'MCP', 'notificationx' ), 'priority' => 45, 'fields' => $this->settings_fields(), ); return $tabs; } /** * Build the MCP settings field schema. The rich panels are server-rendered * HTML delivered through quickbuilder `message` fields (html => true); the * action buttons use quickbuilder `button` fields for the ajax + toast. * * @return array */ protected function settings_fields() { $enabled_rule = Rules::is( 'enable_mcp', true ); $fields = array( 'mcp_main_section' => array( 'name' => 'mcp_main_section', 'type' => 'section', 'label' => __( 'MCP Server', 'notificationx' ), 'fields' => array( 'mcp_hero' => array( 'name' => 'mcp_hero', 'type' => 'message', 'html' => true, 'message' => $this->hero_html(), ), 'enable_mcp' => array( 'name' => 'enable_mcp', 'type' => 'toggle', 'default' => false, 'label' => __( 'Enable MCP access', 'notificationx' ), 'help' => __( 'When enabled and saved, approved AI assistants can connect to this site to manage notifications and read analytics.', 'notificationx' ), ), ), ), 'mcp_connection_section' => array( 'name' => 'mcp_connection_section', 'type' => 'section', 'label' => __( 'Connection', 'notificationx' ), 'rules' => $enabled_rule, 'fields' => array( 'mcp_connection_html' => array( 'name' => 'mcp_connection_html', 'type' => 'message', 'html' => true, 'message' => $this->connection_html(), ), ), ), 'mcp_clients_section' => array( 'name' => 'mcp_clients_section', 'type' => 'section', 'label' => __( 'Connect a client', 'notificationx' ), 'rules' => $enabled_rule, 'fields' => array( 'mcp_clients_html' => array( 'name' => 'mcp_clients_html', 'type' => 'message', 'html' => true, 'message' => $this->clients_html(), ), ), ), 'mcp_apps_section' => array( 'name' => 'mcp_apps_section', 'type' => 'section', 'label' => __( 'Connected apps', 'notificationx' ), 'rules' => $enabled_rule, 'fields' => array( 'mcp_apps_html' => array( 'name' => 'mcp_apps_html', 'type' => 'message', 'html' => true, 'message' => $this->connected_apps_html(), ), ), ), 'mcp_health_section' => array( 'name' => 'mcp_health_section', 'type' => 'section', 'label' => __( 'Connection health', 'notificationx' ), 'rules' => $enabled_rule, 'fields' => array( 'mcp_health_html' => array( 'name' => 'mcp_health_html', 'type' => 'message', 'html' => true, 'message' => $this->health_html(), ), ), ), ); return $fields; } /** * Current status: off | setup | active. * * @return array [ state, label ] */ protected function status() { if ( ! $this->is_enabled() ) { return array( 'off', __( 'Off', 'notificationx' ) ); } if ( Pairing::get_instance()->is_connected() ) { return array( 'active', __( 'Active', 'notificationx' ) ); } return array( 'setup', __( 'Setup needed', 'notificationx' ) ); } /** * Hero header with the status badge. * * @return string */ protected function hero_html() { list( $state, $label ) = $this->status(); ob_start(); ?>
🔌

connector_url(); $token = Pairing::get_instance()->site_token(); ob_start(); ?>

••••••••••••

connector_url() ); ob_start(); ?>
  1. ' . $url . '' ); ?>
  1. .', 'notificationx' ); ?>
state(); if ( $pairing->is_connected() && ! empty( $pstate['last_used'] ) ) { $apps[] = array( 'type' => 'pairing', 'client_id' => '', 'name' => __( 'Token connection (ChatGPT / Cursor / manual)', 'notificationx' ), 'read_only' => $pairing->is_read_only(), ); } foreach ( OAuth::get_instance()->list_active_clients() as $client ) { $apps[] = array( 'type' => 'oauth', 'client_id' => $client['client_id'], 'name' => $client['name'], 'read_only' => ! empty( $client['read_only'] ), ); } return $apps; } protected function connected_apps_html() { $apps = $this->get_connected_apps(); ob_start(); ?>
' . esc_html__( 'No AI clients are connected yet.', 'notificationx' ) . '

'; } else { echo '
'; foreach ( $apps as $app ) { $scope_class = $app['read_only'] ? 'nx-mcp-scope-ro' : 'nx-mcp-scope-rw'; $scope_label = $app['read_only'] ? __( 'Read-only', 'notificationx' ) : __( 'Read & write', 'notificationx' ); ?>
'; } ?>
connector_url() ); ?>

esc_url_raw( rest_url( 'notificationx/v1/mcp/self-test' ) ), 'rotate' => esc_url_raw( rest_url( 'notificationx/v1/mcp/rotate' ) ), 'disconnect' => esc_url_raw( rest_url( 'notificationx/v1/mcp/disconnect' ) ), 'revoke' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps/revoke' ) ), 'apps' => esc_url_raw( rest_url( 'notificationx/v1/mcp/apps' ) ), ); $i18n = array( 'revoke' => __( 'Revoke', 'notificationx' ), 'empty' => __( 'No AI clients are connected yet.', 'notificationx' ), 'refreshFailed' => __( 'Could not refresh the connected apps.', 'notificationx' ), 'revokeConfirm' => __( 'Revoke this connection? The client will need to reconnect.', 'notificationx' ), // Refresh outcomes: say what actually changed, not just a count. 'noneStill' => __( 'No apps connected yet.', 'notificationx' ), 'upToDate' => __( 'Up to date — nothing changed.', 'notificationx' ), 'addedOne' => __( '1 new app connected.', 'notificationx' ), /* translators: %d: number of newly connected apps. */ 'addedMany' => __( '%d new apps connected.', 'notificationx' ), 'removedOne' => __( '1 app disconnected.', 'notificationx' ), /* translators: %d: number of disconnected apps. */ 'removedMany' => __( '%d apps disconnected.', 'notificationx' ), 'changed' => __( 'Connected apps updated.', 'notificationx' ), ); ?> get_status(); $headers = $response->get_headers(); $data = $response->get_data(); if ( ! isset( $headers['Content-Type'] ) ) { header( 'Content-Type: application/json; charset=utf-8' ); } foreach ( $headers as $key => $value ) { header( $key . ': ' . $value ); } // Set the status LAST. Emitting an auth header such as WWW-Authenticate // after the status resets the code to 401 in this SAPI, so the status // must be asserted after every other header() call. status_header( $status ); if ( function_exists( 'http_response_code' ) ) { http_response_code( $status ); } if ( 202 === $status || null === $data ) { exit; } echo wp_json_encode( $data ); exit; } }