( 'admin.php' ) ), 'settings_url' => add_query_arg( array( 'page' => self::PAGE_SLUG ), admin_url( 'admin.php' ) ), 'log_url' => add_query_arg( array( 'page' => self::LOG_PAGE_SLUG ), admin_url( 'admin.php' ) ), 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE_ACTION ), 'ajax_clear_log' => self::AJAX_CLEAR_LOG, ); include PYS_FREE_PATH . '/includes/views/html-main-mcp-log.php'; } /** * Sanitize the log-subpage filter params from `$_GET`. `since` is the * resolved unix cutoff for the `period` shortcut (0 = no cutoff). * * @return array{tool:string, status:string, source:string, period:string, since:int, search:string, paged:int} */ private function logFiltersFromRequest(): array { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only filter params $tool = isset( $_GET[ 'pys_tool' ] ) ? sanitize_text_field( wp_unslash( $_GET[ 'pys_tool' ] ) ) : ''; $status = isset( $_GET[ 'pys_status' ] ) ? sanitize_text_field( wp_unslash( $_GET[ 'pys_status' ] ) ) : ''; $source = isset( $_GET[ 'pys_source' ] ) ? sanitize_text_field( wp_unslash( $_GET[ 'pys_source' ] ) ) : ''; $period = isset( $_GET[ 'pys_period' ] ) ? sanitize_text_field( wp_unslash( $_GET[ 'pys_period' ] ) ) : ''; $search = isset( $_GET[ 'pys_search' ] ) ? sanitize_text_field( wp_unslash( $_GET[ 'pys_search' ] ) ) : ''; $paged = isset( $_GET[ 'paged' ] ) ? max( 1, (int) $_GET[ 'paged' ] ) : 1; // phpcs:enable if ( !in_array( $status, array( Provenance::STATUS_SUCCESS, Provenance::STATUS_ERROR, Provenance::STATUS_PREVIEW ), true ) ) { $status = ''; } if ( !in_array( $source, array( Provenance::SOURCE_PYS, Provenance::SOURCE_EXTERNAL ), true ) ) { $source = ''; } $periodMap = array( '24h' => DAY_IN_SECONDS, '7d' => 7 * DAY_IN_SECONDS, '30d' => 30 * DAY_IN_SECONDS, ); if ( !isset( $periodMap[ $period ] ) ) { $period = ''; } return array( 'tool' => $tool, 'status' => $status, 'source' => $source, 'period' => $period, 'since' => '' === $period ? 0 : time() - $periodMap[ $period ], 'search' => $search, 'paged' => $paged, ); } // ----------------------------------------------------------- AJAX handlers /** * Generate a new named token. The raw value is returned once in this * response — the only time it leaves the server. * * @return void Sends a JSON response and exits. */ public function ajaxGenerateToken(): void { $this->preflight(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked in preflight() $label = isset( $_POST[ 'label' ] ) ? sanitize_text_field( wp_unslash( $_POST[ 'label' ] ) ) : ''; $result = Auth::generate( $label ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 409 ); } wp_send_json_success( array( 'token' => $result[ 'token' ], 'id' => $result[ 'id' ], 'label' => $result[ 'label' ], 'state' => Auth::state(), 'tokens' => Auth::tokens(), ) ); } /** * Revoke one token by id, or all tokens when `all` is set. Always succeeds * (no-op if the id is unknown). * * @return void Sends a JSON response and exits. */ public function ajaxRevokeToken(): void { $this->preflight(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked in preflight() $all = !empty( $_POST[ 'all' ] ); $id = isset( $_POST[ 'id' ] ) ? sanitize_text_field( wp_unslash( $_POST[ 'id' ] ) ) : ''; // phpcs:enable if ( $all ) { $removed = Auth::revokeAll(); } else { $removed = Auth::revoke( $id ) ? 1 : 0; } wp_send_json_success( array( 'removed' => $removed, 'state' => Auth::state(), 'tokens' => Auth::tokens(), ) ); } /** * Infrastructure-level connection check (not a real round-trip ping). * * @return void Sends a JSON response and exits. */ public function ajaxTestConnection(): void { $this->preflight(); wp_send_json_success( array( 'checks' => $this->infrastructureChecks() ) ); } /** * Wipe provenance log. Returns the new (empty) recent-activity list. * * @return void Sends a JSON response and exits. */ public function ajaxClearLog(): void { $this->preflight(); $deleted = Provenance::clearAll(); wp_send_json_success( array( 'deleted' => $deleted, 'recent_activity' => Provenance::getRecent( 20 ), ) ); } // --------------------------------------------------------------- internal /** * Nonce + capability guard for AJAX. Sends a 403 JSON response and exits * if either fails — never returns to the handler. * * @return void */ private function preflight(): void { if ( !current_user_can( self::CAPABILITY ) ) { wp_send_json_error( array( 'message' => 'Insufficient capability.' ), 403 ); } check_ajax_referer( self::NONCE_ACTION, 'nonce' ); } /** * State of the "Other MCP servers" channels. `WP_MCP_VERSION` is set only * by the adapter plugin, not by copies bundled in other plugins. * * @return array{enabled:bool, adapter_detected:bool, adapter_version:string, adapter_path:string, rest_available:bool, rest_path:string} */ private function externalAccessStatus(): array { if ( !function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $adapter_detected = defined( 'WP_MCP_VERSION' ) || is_plugin_active( 'mcp-adapter/mcp-adapter.php' ); $adapter_version = defined( 'WP_MCP_VERSION' ) ? (string) WP_MCP_VERSION : ''; return array( 'enabled' => Capabilities::isExternalAccessAllowed(), 'adapter_detected' => $adapter_detected, 'adapter_version' => $adapter_version, 'adapter_path' => '/' . rest_get_url_prefix() . '/mcp/mcp-adapter-default-server', 'rest_available' => function_exists( 'wp_register_ability' ), 'rest_path' => '/' . rest_get_url_prefix() . '/wp-abilities/v1', ); } /** * Lightweight server-side checks the Test Connection button surfaces. * Each entry is `[label, ok(bool), detail(string)]`, plus `info` for rows * that report a state rather than pass/fail. Order matters — displayed * top-down in the UI. * * @return array */ private function infrastructureChecks(): array { $tokenActive = Auth::STATE_ACTIVE === Auth::state(); $abilitiesApi = function_exists( 'wp_register_ability' ); $adapterLoaded = class_exists( '\\PYS_PRO_GLOBAL\\WP\\MCP\\Plugin' ); $routeRegistered = false; $server = rest_get_server(); if ( is_object( $server ) && method_exists( $server, 'get_routes' ) ) { $routes = $server->get_routes(); $expectedRoute = '/' . trim( $this->routeNamespace, '/' ) . '/' . trim( $this->route, '/' ); $routeRegistered = isset( $routes[ $expectedRoute ] ); } return array( array( 'label' => 'Token state', 'ok' => $tokenActive, 'detail' => $tokenActive ? 'Active' : 'No token generated yet.', ), array( 'label' => 'Abilities API (WP 6.9+)', 'ok' => $abilitiesApi, 'detail' => $abilitiesApi ? 'wp_register_ability() available' : 'Function missing — upgrade WordPress.', ), array( 'label' => 'MCP Adapter', 'ok' => $adapterLoaded, 'detail' => $adapterLoaded ? 'PYS_PRO_GLOBAL\\WP\\MCP\\Plugin loaded' : 'Prefixed adapter not found in vendor_prefix/.', ), array( 'label' => 'REST route', 'ok' => $routeRegistered, 'detail' => $routeRegistered ? $this->endpointUrl() : 'Route not registered in WP REST API.', ), array( 'label' => 'Write mode', 'ok' => Capabilities::isWriteAllowed(), 'detail' => Capabilities::isWriteAllowed() ? 'Writes enabled' : 'Read-only mode is on — set_* tools are blocked.', ), array( 'label' => 'Other MCP servers', 'ok' => true, 'info' => true, 'detail' => $this->externalAccessDetail( $this->externalAccessStatus() ), ), ); } /** * Connection-test line for the "Other MCP servers" state. * * @param array $status {@see externalAccessStatus()}. * @return string */ private function externalAccessDetail( array $status ): string { $state = $status[ 'enabled' ] ? 'On' : 'Off'; if ( !$status[ 'adapter_detected' ] ) { return $state . ' — WordPress MCP Adapter not detected; PixelYourSite tools are ' . ( $status[ 'enabled' ] ? 'available to' : 'hidden from' ) . ' the WordPress abilities REST API.'; } $adapter = trim( 'WordPress MCP Adapter ' . $status[ 'adapter_version' ] ); return $state . ' — ' . $adapter . ' detected, PixelYourSite tools ' . ( $status[ 'enabled' ] ? 'available to it.' : 'hidden from it.' ); } }