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' ); ?> >
••••••••••••
connector_url() ); ?>