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 ); add_filter( 'nx_protected_settings', array( $this, 'protect_enable_setting' ) ); // CSS + JS for the MCP panel (copy / reveal / revoke controls). add_action( 'admin_print_footer_scripts', array( $this, 'print_panel_assets' ) ); add_action( 'admin_init', array( $this, 'redirect_hidden_tab' ) ); } /** * Send a user who cannot see the MCP tab (see register_settings_tab()) from * a `?tab=tab-mcp` link to the settings screen's first tab, rather than to * an empty screen. * * @return void */ public function redirect_hidden_tab() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only navigation check. if ( wp_doing_ajax() || ! isset( $_GET['page'], $_GET['tab'] ) || 'nx-settings' !== $_GET['page'] || 'tab-mcp' !== $_GET['tab'] ) { return; } // phpcs:enable if ( current_user_can( 'manage_options' ) ) { return; } wp_safe_redirect( remove_query_arg( 'tab' ) ); exit; } /** * 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). // Discovery over REST as well as `/.well-known/`. The well-known path is // a single namespace the whole site shares: another plugin that hooks // `parse_request` earlier, or a host that answers `/.well-known/` itself // (ACME), takes it and our clients then read someone else's metadata. // A route inside our own REST namespace cannot be taken, so that is what // Server::with_challenge() advertises. Public, like the documents // themselves. register_rest_route( $ns, '/mcp/oauth/protected-resource', array( 'methods' => 'GET', 'callback' => array( $this, 'rest_protected_resource' ), 'permission_callback' => '__return_true', ) ); register_rest_route( $ns, '/mcp/oauth/authorization-server', array( 'methods' => 'GET', 'callback' => array( $this, 'rest_authorization_server' ), 'permission_callback' => '__return_true', ) ); 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, ) ); // The enable toggle persists through here rather than the settings form. // The settings endpoint replaces the whole settings blob with whatever the // admin app posts (see Admin\Settings::save_settings()), so a request // carrying only `enable_mcp` would wipe every other setting. This writes // the one key and leaves the rest alone. register_rest_route( $ns, '/mcp/enable', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_set_enabled' ), 'permission_callback' => $admin, 'args' => array( 'enabled' => array( 'required' => true, 'type' => 'boolean', ), ), ) ); 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() { // Whatever switched MCP on -- the toggle, or the settings form's Save -- // the panel asks here for the state to display, so make sure there is a // token to hand back rather than reporting an empty one until a reload. $this->ensure_paired(); 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() { // A token is normally minted while the settings tab is built, which only // happens on a server-rendered request. Saving from the admin app never // rebuilds it, so a test run straight after switching MCP on used to // report "no connection token has been generated yet" until the page was // reloaded. Mint here too, so the test reflects the saved state. $this->ensure_paired(); $result = SelfTest::get_instance()->run(); return new \WP_REST_Response( array( 'status' => $result['ok'] ? 'success' : 'error', 'message' => $result['message'] ) + $result, 200 ); } /** * Turn MCP access on or off. * * Writes only `settings.enable_mcp`: the settings endpoint replaces the whole * blob with the posted one, so persisting the toggle through there would * require the admin app to post every other setting alongside it. * * @param \WP_REST_Request $request Incoming request. * @return \WP_REST_Response */ public function rest_set_enabled( $request ) { $enabled = (bool) $request->get_param( 'enabled' ); $settings = Settings::get_instance()->get( 'settings' ); if ( ! is_array( $settings ) ) { $settings = array(); } $settings['enable_mcp'] = $enabled; Settings::get_instance()->set( 'settings', $settings ); // Same courtesy the settings tab does: switching on should leave the UI // with a token to show and a connection that can actually be tested. $this->ensure_paired(); return new \WP_REST_Response( array( 'status' => 'success' ) + $this->connection_state(), 200 ); } /** * Mint a pairing token if MCP is on and none exists yet. Idempotent, and a * no-op while MCP is off so turning it off never creates credentials. * * @return void */ protected function ensure_paired() { if ( ! $this->is_enabled() ) { return; } $pairing = Pairing::get_instance(); if ( ! $pairing->is_connected() ) { $pairing->connect(); } } /** * 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). Only our // own documents are served, and only while MCP is switched on: another // MCP plugin on the same site owns `.well-known/...`/, // and answering that with our metadata would point its clients at our // authorization server. With MCP off we own no resource to describe, so // the request falls through to WordPress instead. if ( $this->is_enabled() ) { if ( $this->owns_discovery_path( $path, 'oauth-authorization-server' ) ) { $this->emit_json( OAuth::get_instance()->authorization_server_metadata() ); } if ( $this->owns_discovery_path( $path, 'oauth-protected-resource' ) ) { $this->emit_json( OAuth::get_instance()->protected_resource_metadata() ); } } // Pretty MCP endpoint. if ( self::ENDPOINT_PATH === $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 ); } /** * The brand mark for a connecting client. * * Clients arrive through open dynamic registration, so the name is whatever * the app sent and anyone can call themselves "Claude". A vendor mark is * therefore only shown when the name matches AND the code is being sent * back to a host that vendor controls; everything else (including loopback * redirects used by desktop apps) falls back to the initial. The files are * the same ones the Connect a client panel uses, so the consent screen and * the admin panel can never show different marks for the same app. * * @param string $name Registered client name. * @param string $redirect_uri Validated redirect URI of this authorize request. * @return array{file:string,tint:string}|array Empty when unrecognised. */ protected static function client_brand( $name, $redirect_uri ) { $brands = array( 'claude' => array( 'file' => 'claude.svg', 'tint' => '#fdf1ec', 'hosts' => array( 'claude.ai', 'claude.com', 'anthropic.com' ) ), 'chatgpt' => array( 'file' => 'chatgpt.svg', 'tint' => '#eaf6f2', 'hosts' => array( 'chatgpt.com', 'openai.com' ) ), 'openai' => array( 'file' => 'chatgpt.svg', 'tint' => '#eaf6f2', 'hosts' => array( 'chatgpt.com', 'openai.com' ) ), 'cursor' => array( 'file' => 'cursor.svg', 'tint' => '#eceaf6', 'hosts' => array( 'cursor.com', 'cursor.sh' ) ), ); $host = strtolower( (string) wp_parse_url( (string) $redirect_uri, PHP_URL_HOST ) ); $scheme = strtolower( (string) wp_parse_url( (string) $redirect_uri, PHP_URL_SCHEME ) ); if ( '' === $host || 'https' !== $scheme ) { return array(); } foreach ( $brands as $needle => $brand ) { if ( false === stripos( (string) $name, $needle ) ) { continue; } foreach ( $brand['hosts'] as $vendor_host ) { if ( $host === $vendor_host || substr( $host, -strlen( '.' . $vendor_host ) ) === '.' . $vendor_host ) { return array( 'file' => $brand['file'], 'tint' => $brand['tint'], ); } } } return array(); } /** * 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 only when we can vouch for it; otherwise the initial. $client_brand = self::client_brand( $name, $request['redirect_uri'] ); // 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 ) : '' ); ?>
▾
  • get_label() ); ?>
    get_description() ); ?>
'tab-mcp', 'label' => __( 'MCP', 'notificationx' ), 'priority' => 45, 'fields' => $this->settings_fields(), ); return $tabs; } /** * Keep `enable_mcp` out of reach of users who cannot manage MCP. * * The settings form posts the whole blob, so without this a user with * settings access but without manage_options could switch MCP on or off, * although every MCP management route requires manage_options. * * @param array $keys Protected settings keys. * @return array */ public function protect_enable_setting( $keys ) { if ( ! current_user_can( 'manage_options' ) ) { $keys = (array) $keys; $keys[] = 'enable_mcp'; } return $keys; } /** * Build the MCP settings field schema. The rich panels are server-rendered * HTML delivered through quickbuilder `message` fields (html => true); the * action buttons are plain buttons wired to the globals printed by * {@see print_panel_assets()}. * * Section order follows the reading order of someone who has never used the * feature: what it is (hero), what it would give them (capabilities), and * only then the plumbing. The capability section deliberately carries no * `rules`, so the one screen that answers "why would I turn this on?" is * also visible while the feature is still off — the rest stays hidden until * it has something real to show. * * @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, 'classes' => 'nx-mcp-field nx-mcp-field-flush', 'message' => $this->hero_html(), ), 'enable_mcp' => array( 'name' => 'enable_mcp', 'type' => 'toggle', 'default' => false, 'label' => __( 'Enable MCP access', 'notificationx' ), 'help' => __( 'When enabled, approved AI assistants can connect to this site to manage notifications and read analytics.', 'notificationx' ), ), 'mcp_stats' => array( 'name' => 'mcp_stats', 'type' => 'message', 'html' => true, 'classes' => 'nx-mcp-field', 'rules' => $enabled_rule, 'message' => $this->stats_html(), ), ), ), '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, 'classes' => 'nx-mcp-field', '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, 'classes' => 'nx-mcp-field', '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, 'classes' => 'nx-mcp-field', '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, 'classes' => 'nx-mcp-field', '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' ) ); } /** * The abilities currently registered, split the way the panel reads them. * * Read straight from the registry rather than a hand-kept list, so the tab * can never claim a tool the server does not actually expose — and so Pro's * abilities appear the moment Pro adds them through `nx_register_abilities` * with no change here. `boot()` is idempotent, and calling it is what makes * this safe to render on a request where nothing else has touched the * registry yet. * * @return array { read: array[], write: array[] } each row: label, tool, pro. */ protected function ability_rows() { $registrar = Registrar::get_instance(); $registrar->boot(); $rows = array( 'read' => array(), 'write' => array(), ); foreach ( $registrar->get_all() as $id => $ability ) { $row = array( 'label' => $ability->get_label(), 'tool' => $ability->tool_name(), 'pro' => ( 0 === strpos( (string) $id, 'notificationx-pro/' ) ), ); $rows[ $ability->is_write() ? 'write' : 'read' ][] = $row; } return $rows; } /** * The three setup steps shown as a static how-to in the hero. * * @return array[] Each: icon, label, hint. */ protected function setup_steps() { return array( array( 'icon' => 'icon-step-power', 'label' => __( 'Turn MCP on', 'notificationx' ), 'hint' => __( 'Flip the switch below.', 'notificationx' ), ), array( 'icon' => 'icon-step-copy', 'label' => __( 'Copy your connector', 'notificationx' ), 'hint' => __( 'One URL, and a token for clients that need one.', 'notificationx' ), ), array( 'icon' => 'icon-step-approve', 'label' => __( 'Approve the client', 'notificationx' ), 'hint' => __( 'Add it in Claude, ChatGPT or Cursor and confirm.', 'notificationx' ), ), ); } /** * Path to one of the tab's own icon files. * * The panel HTML is rendered into the settings app through a `message` * field, where an inline `` does not survive: icons are therefore real * files referenced with ``, never markup and never a `data:` URI. * * @param string $name File name, without extension. * @return string */ protected function icon_url( $name ) { return NOTIFICATIONX_ADMIN_URL . 'images/mcp/' . $name . '.svg'; } /** * Hero header: what the feature is, where the site currently stands, and * the three steps between here and a working connection. * * @return string */ protected function hero_html() { list( $state, $label ) = $this->status(); $steps = $this->setup_steps(); ob_start(); ?>
status(); $pairing = Pairing::get_instance(); $pstate = $pairing->state(); $connected_at = ! empty( $pstate['connected_at'] ) ? (int) $pstate['connected_at'] : 0; $last_used = ! empty( $pstate['last_used'] ) ? (int) $pstate['last_used'] : 0; $rows = $this->ability_rows(); $tool_count = count( $rows['read'] ) + count( $rows['write'] ); $pro_count = 0; foreach ( array_merge( $rows['read'], $rows['write'] ) as $row ) { if ( $row['pro'] ) { ++$pro_count; } } $apps = count( $this->get_connected_apps() ); $tiles = array( array( 'key' => 'status', 'icon' => 'icon-status', 'label' => __( 'Server status', 'notificationx' ), 'value' => $status_label, 'small' => true, 'note' => $connected_at /* translators: %s: the date the connection was established. */ ? sprintf( __( 'since %s', 'notificationx' ), date_i18n( get_option( 'date_format' ), $connected_at ) ) : '', ), array( 'icon' => 'icon-tools', 'label' => __( 'Tools exposed', 'notificationx' ), 'value' => number_format_i18n( $tool_count ), 'small' => false, 'note' => $pro_count /* translators: %s: number of Pro-only tools. */ ? sprintf( _n( '%s from Pro', '%s from Pro', $pro_count, 'notificationx' ), number_format_i18n( $pro_count ) ) : __( 'more with Pro', 'notificationx' ), ), array( 'icon' => 'icon-apps', 'label' => __( 'Connected apps', 'notificationx' ), 'value' => number_format_i18n( $apps ), 'small' => false, 'note' => $apps ? '' : __( 'none yet', 'notificationx' ), ), array( 'icon' => 'icon-activity', 'label' => __( 'Last activity', 'notificationx' ), 'value' => $last_used /* translators: %s: human-readable time difference, e.g. "5 mins". */ ? sprintf( __( '%s ago', 'notificationx' ), human_time_diff( $last_used ) ) : __( 'Never', 'notificationx' ), 'small' => true, 'note' => '', ), ); ob_start(); ?>
connector_url(); ob_start(); ?>

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

connector_url(); ob_start(); ?>
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' ) ), 'enable' => esc_url_raw( rest_url( 'notificationx/v1/mcp/enable' ) ), 'connection' => esc_url_raw( rest_url( 'notificationx/v1/mcp/connection' ) ), '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' ), // Enable toggle outcomes. 'enabled' => __( 'MCP access enabled.', 'notificationx' ), 'disabled' => __( 'MCP access disabled.', 'notificationx' ), 'enableFailed' => __( 'Could not save the MCP setting.', 'notificationx' ), // Generic action outcomes. 'genericError' => __( 'Something went wrong.', 'notificationx' ), 'requestFailed' => __( 'Request failed.', 'notificationx' ), 'done' => __( 'Done.', 'notificationx' ), 'revoked' => __( 'Connection revoked.', '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' ), 'statusActive' => __( 'Active', 'notificationx' ), 'statusOff' => __( 'Off', 'notificationx' ), 'copied' => __( 'Copied', 'notificationx' ), 'tokenMissing' => __( 'The token is not on screen yet. Reload the page and try again.', 'notificationx' ), 'configCopied' => __( 'Server block copied. Paste it into your client’s MCP config.', 'notificationx' ), ); ?> is_enabled() ) { return $this->discovery_disabled(); } return new \WP_REST_Response( OAuth::get_instance()->protected_resource_metadata(), 200 ); } /** * RFC 8414 authorization-server metadata, served from our own REST namespace. * * @return \WP_REST_Response */ public function rest_authorization_server() { if ( ! $this->is_enabled() ) { return $this->discovery_disabled(); } return new \WP_REST_Response( OAuth::get_instance()->authorization_server_metadata(), 200 ); } /** * The response for a discovery request made while MCP is switched off. * A 404 keeps us indistinguishable from a site that never shipped MCP, so * a client cannot read our settings state from the discovery surface. * * @return \WP_Error */ protected function discovery_disabled() { return new \WP_Error( 'rest_no_route', __( 'No route was found matching the URL and request method.', 'notificationx' ), array( 'status' => 404 ) ); } /** * Whether an OAuth discovery path belongs to this plugin. * * The handler runs on `parse_request` at priority 0 and `emit_json()` * exits, so whatever it answers is final -- nothing later in the request * gets a say. A prefix match would therefore serve our metadata for * *any* suffix, including another MCP plugin's * `.well-known/oauth-protected-resource//mcp`, sending * their clients to our authorization server (RFC 9728 requires the * resource to match exactly, so their handshake then fails). * * Two forms are ours, and only those two: * * - the bare document, which our own `WWW-Authenticate` challenge * advertises (see Server::with_challenge()); * - the RFC 9728 path-suffixed form for our endpoint. * * Anything else is declined by returning false, so the request falls * through to whichever plugin does own it -- deliberately not a 404, * which would break that neighbour just as effectively. * * @param string $path Request path, relative to home and unslashed. * @param string $doc Discovery document name. * @return bool */ protected function owns_discovery_path( $path, $doc ) { $base = '.well-known/' . $doc; return $path === $base || $path === $base . '/' . self::ENDPOINT_PATH; } /** * The request path relative to the WordPress home path, without query string. * * @return string */ protected function request_path() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- parsed below. $uri = esc_url_raw( $uri ); $path = wp_parse_url( $uri, PHP_URL_PATH ); if ( ! $path ) { return ''; } $home_path = wp_parse_url( home_url(), PHP_URL_PATH ); if ( $home_path && 0 === strpos( $path, $home_path ) ) { $path = substr( $path, strlen( $home_path ) ); } return trim( $path, '/' ); } /** * Emit an array as a JSON document and stop. * * @param array $data Payload. * @return void */ protected function emit_json( $data ) { nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Access-Control-Allow-Origin: *' ); header( 'Cache-Control: public, max-age=3600' ); echo wp_json_encode( $data ); exit; } /** * Emit a WP_REST_Response (status + headers + JSON body) and stop. * * @param \WP_REST_Response $response Response. * @return void */ protected function emit_rest_response( $response ) { $status = $response->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; } }