getMessage()`, `$wpdb->last_error`, transient key strings, or * file paths MUST NEVER appear in the response `message` field. Internal * diagnostics MAY be logged via `error_log()` — never surfaced to the client. * * F017/F082 abilities integration (TASK-SEC-002 / T022): Step 5 "Enable all * abilities" DOES NOT round-trip through the abilities REST routes. It calls * the shared `MCPServer\PolicyTransition::apply()` service — since F082 that * means flipping the server-level `abilities_default_policy` to `'expose'` * and clearing the override rows (see `apply_step_5()`), not one upsert per * ability. Single auth check (this controller's outer permission_callback), * no internal REST-to-REST nonce lifecycle question, no cross-controller * REST-wire coupling. Matches `DEC-ABILITY-OVERRIDE-RESOLUTION` service-call * intent. * * @package AcrossAI_MCP_Manager * @subpackage Includes/REST * @since 0.2.11 */ declare( strict_types = 1 ); namespace AcrossAI_MCP_Manager\Includes\REST; use AcrossAI_MCP_Manager\Includes\Database\MCPServer\PolicyTransition; use AcrossAI_MCP_Manager\Includes\Database\MCPServer\Query as MCPServerQuery; use AcrossAI_MCP_Manager\Includes\Database\MCPServer\ServerEnablement; use AcrossAI_MCP_Manager\Includes\Database\MCPServer\NonConnectableServers; use AcrossAI_MCP_Manager\Includes\Database\MCPServer\ServerTypes; use AcrossAI_MCP_Manager\Includes\Database\MCPServer\ToolPolicy; use AcrossAI_MCP_Manager\Includes\Database\MCPServerAbility\ExposureResolver as MCPServerAbilityExposureResolver; use AcrossAI_MCP_Manager\Includes\Utilities\MCPServerFieldSanitizer; use AcrossAI_MCP_Manager\Public\Discovery\ConnectionMethodRegistry; use WP_Error; use WP_REST_Request; use WP_REST_Response; use WP_REST_Server; defined( 'ABSPATH' ) || exit; /** * Quick Connect via AcrossAI wizard REST controller. * * @since 0.2.11 */ final class QuickConnectController { /** * REST namespace. */ private const REST_NAMESPACE = 'acrossai-mcp-manager/v1'; /** * REST route prefix (relative to namespace). */ private const ROUTE_PREFIX = '/quick-connect'; /** * Per-user scratchpad transient key prefix. The current user id is * appended at read/write time (data-model.md E1). */ private const SCRATCHPAD_KEY_PREFIX = 'acrossai_mcp_manager_quick_connect_state_'; /** * Scratchpad TTL — 30 minutes per FR-026. */ private const SCRATCHPAD_TTL = 1800; /** * Valid step values accepted on POST /step. * * The wizard is a dynamic flow that surfaces between ~5 and ~10 steps * to any given user depending on state. Steps 2, 4, 5, 6, 8-13 are * conditional and skipped at the routing layer when their precondition * is (or isn't) met: * - Step 2 (server create) — skipped when Step 1 picked an existing server * - Step 4 (abilities-manager gate) — skipped when the plugin is active * - Step 5 (abilities picker) — skipped when all abilities are enabled * - Step 6 (enable endpoint) — skipped when the server is already enabled * - Step 8 (Pro pitch) — only shown when method=connectors AND pro is missing * - Step 9 (Pro activate) — only shown when method=connectors AND pro is inactive * - Step 10 (Connectors detail) — only shown when method=connectors AND pro is active * - Step 11 (MCP Client detail) — only shown when method=client * - Step 12 (npm detail) — only shown when method=npm * - Step 13 (WP-CLI detail) — only shown when method=wpcli * Every step still has a valid backend handler; skipping is purely a * routing-layer optimization. */ private const VALID_STEPS = array( 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 ); /** * Valid method values accepted on step 7 (connection method). */ private const VALID_METHODS = array( 'connectors', 'client', 'npm', 'wpcli' ); /** * Plugin slugs the /install-plugin route is allowed to install from wp.org. * Any slug not on this whitelist is rejected 400 (defense in depth on top * of the install_plugins/activate_plugins capability check). */ private const INSTALLABLE_PLUGIN_SLUGS = array( 'acrossai-abilities-manager', ); /** * Singleton instance. * * @since 0.2.11 * @var self|null */ protected static $_instance = null; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore /** * Singleton accessor. * * @since 0.2.11 * @return self */ public static function instance(): self { if ( null === self::$_instance ) { self::$_instance = new self(); } return self::$_instance; } /** * Private constructor enforces singleton pattern (S6). * * @since 0.2.11 */ private function __construct() {} // ───────────────────────────────────────────────────────────────────── // Route registration // ───────────────────────────────────────────────────────────────────── /** * Register the three quick-connect routes. Wired on `rest_api_init` via * `Main::define_public_hooks()` (T024). * * @since 0.2.11 * @return void */ public function register_routes(): void { register_rest_route( self::REST_NAMESPACE, self::ROUTE_PREFIX . '/state', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'handle_state' ), 'permission_callback' => array( $this, 'permission_check' ), ) ); register_rest_route( self::REST_NAMESPACE, self::ROUTE_PREFIX . '/step', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_step' ), 'permission_callback' => array( $this, 'permission_check' ), ) ); register_rest_route( self::REST_NAMESPACE, self::ROUTE_PREFIX . '/complete', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_complete' ), 'permission_callback' => array( $this, 'permission_check' ), ) ); register_rest_route( self::REST_NAMESPACE, self::ROUTE_PREFIX . '/install-plugin', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_install_plugin' ), 'permission_callback' => array( $this, 'install_plugin_permission_check' ), ) ); } /** * Permission callback for /install-plugin. Requires BOTH capabilities so * a user who can install but not activate (or vice versa) gets rejected * up front rather than half-way through the pipeline. * * @since 0.2.11 * @return bool */ public function install_plugin_permission_check(): bool { return current_user_can( 'install_plugins' ) && current_user_can( 'activate_plugins' ); } /** * Shared permission callback for all three routes. Boolean per S2. * WP REST layer additionally validates X-WP-Nonce automatically for * cookie-authenticated requests when the nonce is present. * * @since 0.2.11 * @return bool */ public function permission_check(): bool { return current_user_can( 'manage_options' ); } // ───────────────────────────────────────────────────────────────────── // Route: GET /quick-connect/state // ───────────────────────────────────────────────────────────────────── /** * Return the full snapshot the React app needs to render every step. * * Inherits TASK-SEC-003 error-hygiene constraint from the class docblock. * * Accepts an optional `?server_id=N` query param — used when the wizard * deep-links straight to a late step (e.g. Step 11 with `?server=1` in * the browser URL) without having written the scratchpad's `server_id` * via the usual server-picker step. Without this fallback, * `ConnectionMethodRegistry::get_clients()` returns metadata-only DTOs * (no `config` field), which the JSX then dumps raw. Query param loses * to scratchpad when both are present — the scratchpad is the * authoritative wizard state. * * @since 0.2.11 * @param WP_REST_Request|null $request Optional request (for `?server_id=` fallback). * @return WP_REST_Response|WP_Error */ public function handle_state( ?WP_REST_Request $request = null ) { $wizard_state = $this->read_scratchpad(); $server_id = isset( $wizard_state['server_id'] ) ? (int) $wizard_state['server_id'] : 0; if ( 0 === $server_id && null !== $request ) { $server_id = (int) $request->get_param( 'server_id' ); } $servers = $this->collect_servers(); $abilities = $this->collect_abilities_summary( $server_id > 0 ? $server_id : null ); $plugins = $this->collect_plugin_states(); $methods = ConnectionMethodRegistry::instance()->get_all(); // F073 — override the generic (server-less) client list with the // server-scoped variant so each DTO carries the real Configuration // JSON for the wizard's active server. Mirrors the Clients tab, which // resolves the same URL in MCPClientsBlock:224-226. Falls back to the // generic list (no `config` field) when no server is selected yet. $setup_required = null; if ( $server_id > 0 ) { $rows = MCPServerQuery::instance()->query( array( 'id' => $server_id, 'number' => 1, ) ); if ( ! empty( $rows ) ) { // The handover gate. Since 0.3.6 the wizard may select, create // AND enable a server whose type needs a plugin that is not // active — that permissiveness is deliberate, because refusing // to enable would strand the wizard for exactly the operator // who has not installed the add-on yet. // // Handing over a client configuration is the step that must // still wait. Such a server answers with one tool, // `toolset/setup-required`, so a config pasted into Claude or // Cursor now would connect and appear broken — and the client // caches its tool list at connect time, so it would keep // appearing broken after the plugin was installed. // // Withholding `config` rather than blocking the step keeps the // wizard walkable: the operator can read what is missing, // install it, and carry on. Re-evaluated on every /state call, // so deactivating the plugin later re-blocks the handover. $notice = ServerTypes::requirement_notice( (string) $rows[0]->server_type ); if ( null !== $notice ) { $setup_required = array( 'code' => $notice->get_error_code(), 'message' => $notice->get_error_message(), 'plugin' => ServerTypes::required_plugin_slug( (string) $rows[0]->server_type ), ); } else { $methods['clients'] = ConnectionMethodRegistry::instance()->get_clients( $rows[0]->to_array() ); } } } // F082 — merge in the per-connector walkthrough HTML lane. Kept // outside get_all()'s three-category shape (whose contract is // locked to npm/clients/ai_connectors) so the discovery API stays // stable while Quick Connect Step 10 still gets the payload from // the same REST call. $methods['ai_connector_instructions'] = ConnectionMethodRegistry::instance()->get_ai_connector_instructions(); return rest_ensure_response( array( 'servers' => $servers, 'abilities' => $abilities, 'plugins' => $plugins, 'methods' => $methods, 'wizardState' => $wizard_state, // Null when the selected server is Ready. Non-null means no // client configuration was emitted above, and this says why. 'setupRequired' => $setup_required, ) ); } // ───────────────────────────────────────────────────────────────────── // Route: POST /quick-connect/step // ───────────────────────────────────────────────────────────────────── /** * Persist per-step scratchpad state + delegate authoritative writes to * existing plugin APIs (MCPServerQuery::add_item/update_item, * PolicyTransition::apply per SEC-002 direct service call). * * Inherits TASK-SEC-003 error-hygiene constraint from the class docblock — * every WP_Error uses a hand-authored user-facing message. * * @since 0.2.11 * @param WP_REST_Request $request Incoming request. * @return WP_REST_Response|WP_Error */ public function handle_step( WP_REST_Request $request ) { $step = (int) $request->get_param( 'step' ); if ( ! in_array( $step, self::VALID_STEPS, true ) ) { return new WP_Error( 'acrossai_mcp_quick_connect_invalid_step', esc_html__( 'Invalid step. Expected an integer from 1 to 5.', 'acrossai-mcp-manager' ), array( 'status' => 400 ) ); } $data = $request->get_param( 'data' ); if ( ! is_array( $data ) ) { $data = array(); } $scratchpad = $this->read_scratchpad(); $refresh_servers = false; switch ( $step ) { case 1: $result = $this->apply_step_1( $data, $scratchpad ); if ( is_wp_error( $result ) ) { return $result; } $scratchpad = $result; break; case 2: $result = $this->apply_step_2( $data, $scratchpad ); if ( is_wp_error( $result ) ) { return $result; } $scratchpad = $result; $refresh_servers = true; break; case 3: $scratchpad['access_saved'] = ! empty( $data['access_saved'] ); break; case 4: // Abilities Manager gate — nothing to persist in the scratchpad. // Plugin activation state is authoritative and read from // state.plugins on GET /state. The step exists in the flow so // clients can POST /step 4 as a "we've been here" ack for // analytics / progress tracking, but no field is required. break; case 5: $result = $this->apply_step_5( $data, $scratchpad ); if ( is_wp_error( $result ) ) { return $result; } $scratchpad = $result; break; case 6: $result = $this->apply_step_6( $data, $scratchpad ); if ( is_wp_error( $result ) ) { return $result; } $scratchpad = $result; $refresh_servers = true; break; case 7: $method = isset( $data['method'] ) ? (string) $data['method'] : ''; if ( ! in_array( $method, self::VALID_METHODS, true ) ) { return new WP_Error( 'acrossai_mcp_quick_connect_invalid_method', esc_html__( 'Invalid connection method. Choose one of: connectors, client, npm, wpcli.', 'acrossai-mcp-manager' ), array( 'status' => 400 ) ); } $scratchpad['method'] = $method; break; case 8: case 9: case 10: case 11: case 12: case 13: // Terminal detail / gate steps — nothing to persist beyond // current_step. Plugin activation state (steps 8, 9) is read // from state.plugins on GET /state; connection details // (steps 10-13) are pure-read display and don't mutate the // scratchpad. Handlers exist so clients can POST /step {N} // as a "we've been here" ack for analytics / progress. break; } $scratchpad['current_step'] = $step; if ( ! $this->write_scratchpad( $scratchpad ) ) { return new WP_Error( 'acrossai_mcp_quick_connect_persist_failed', esc_html__( 'Failed to save your progress. Try again in a moment.', 'acrossai-mcp-manager' ), array( 'status' => 500 ) ); } $response = array( 'wizardState' => $scratchpad ); if ( $refresh_servers ) { $response['servers'] = $this->collect_servers(); } // Always ship a fresh abilities summary when the scratchpad has a // server. The `enabledForServer` count is only computed from a // server_id, and it's used by the frontend's skipAbilities skip // predicate (auto-skip Step 5 when everything is enabled). Without // this piggyback, `enabledForServer` would only be refreshed on // GET /state — meaning after a user picks a server on Step 1, the // count would stay null until a manual focus-refetch, and Step 5 // would fail to auto-skip even when it should. $server_id = isset( $scratchpad['server_id'] ) ? (int) $scratchpad['server_id'] : 0; if ( $server_id > 0 ) { $response['abilities'] = $this->collect_abilities_summary( $server_id ); } return rest_ensure_response( $response ); } // ───────────────────────────────────────────────────────────────────── // Route: POST /quick-connect/complete // ───────────────────────────────────────────────────────────────────── /** * Clear the per-user scratchpad. 204 No Content on success. Idempotent. * * Inherits TASK-SEC-003 error-hygiene constraint from the class docblock. * * @since 0.2.11 * @return WP_REST_Response */ public function handle_complete() { delete_transient( self::SCRATCHPAD_KEY_PREFIX . get_current_user_id() ); return new WP_REST_Response( null, 204 ); } // ───────────────────────────────────────────────────────────────────── // Route: POST /quick-connect/install-plugin // ───────────────────────────────────────────────────────────────────── /** * Resolve an installed plugin's main file from its FOLDER slug. * * Matches by directory prefix rather than assuming `slug/slug.php` — see the * caller for why that convention cannot be trusted. The trailing slash on the * prefix is what stops `acf` matching `acf-pro`. * * @since 0.1.0 * @param string $slug Plugin folder slug. * @return string|null `folder/file.php`, or null when not installed. */ private function find_installed_plugin_file( string $slug ): ?string { $fallback = null; // A plugin DIRECTORY can hold more than one file carrying a plugin // header, and `get_plugins()` lists each. Taking whichever comes first // could activate a secondary file, so prefer the conventionally-named // one when the directory actually contains it — that keeps the previous // behaviour everywhere it was already correct — and fall back to the // first directory match, which is what makes `ihaf.php` and // `wp_mail_smtp.php` resolvable at all. // // The preference is expressed by INSPECTING what exists, never by // building `slug/slug.php` and hoping. That distinction is the whole // point, and `bin/verify-f021-gates.sh` enforces it. foreach ( array_keys( (array) get_plugins() ) as $candidate ) { $candidate = (string) $candidate; if ( 0 !== strpos( $candidate, $slug . '/' ) ) { continue; } if ( basename( $candidate, '.php' ) === $slug ) { return $candidate; } if ( null === $fallback ) { $fallback = $candidate; } } return $fallback; } /** * Install (if missing) and activate a whitelisted plugin from WordPress.org. * * Only accepts slugs on `INSTALLABLE_PLUGIN_SLUGS`. If the plugin is * already installed, skips the download step and only activates. If it's * already active, returns success (idempotent). * * Inherits TASK-SEC-003 error-hygiene constraint from the class docblock — * upgrader / activate errors are logged but never surfaced verbatim. * * @since 0.2.11 * @param WP_REST_Request $request Incoming request. * @return WP_REST_Response|WP_Error */ public function handle_install_plugin( WP_REST_Request $request ) { $slug = isset( $request['slug'] ) ? sanitize_key( (string) $request['slug'] ) : ''; if ( ! in_array( $slug, self::INSTALLABLE_PLUGIN_SLUGS, true ) ) { return new WP_Error( 'acrossai_mcp_quick_connect_invalid_plugin', esc_html__( 'That plugin cannot be installed from here.', 'acrossai-mcp-manager' ), array( 'status' => 400 ) ); } require_once ABSPATH . 'wp-admin/includes/plugin.php'; require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/misc.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; // Resolve the plugin's REAL main file by DIRECTORY. The `slug/slug.php` // convention is not a rule WordPress enforces: three plugins active on // the dev site break it (`insert-headers-and-footers/ihaf.php`, // `sfwd-lms/sfwd_lms.php`, `wp-mail-smtp/wp_mail_smtp.php`). It happens // to hold for both allow-listed slugs today, so guessing would work // until someone adds a third — at which point Quick Connect would // reinstall a plugin that is already present and then fail to activate // it. Deliberately NO convention fallback: an unresolvable file is an // error, not a guess. $plugin_file = $this->find_installed_plugin_file( $slug ); if ( null === $plugin_file ) { $api = plugins_api( 'plugin_information', array( 'slug' => $slug, 'fields' => array( 'sections' => false ), ) ); if ( is_wp_error( $api ) ) { error_log( sprintf( '[acrossai-mcp-manager] plugins_api failed for %s: %s', $slug, $api->get_error_message() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Deliberate diagnostics per file-header error-hygiene policy (internal log only, never surfaced to the client). return new WP_Error( 'acrossai_mcp_quick_connect_install_failed', esc_html__( 'Could not find that plugin on WordPress.org. Try installing it manually from Plugins → Add New.', 'acrossai-mcp-manager' ), array( 'status' => 502 ) ); } $upgrader = new \Plugin_Upgrader( new \WP_Ajax_Upgrader_Skin() ); $result = $upgrader->install( $api->download_link ); if ( is_wp_error( $result ) ) { error_log( sprintf( '[acrossai-mcp-manager] Plugin_Upgrader::install failed for %s: %s', $slug, $result->get_error_message() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Deliberate diagnostics per file-header error-hygiene policy (internal log only, never surfaced to the client). return new WP_Error( 'acrossai_mcp_quick_connect_install_failed', esc_html__( 'Installation failed. Try installing manually from Plugins → Add New.', 'acrossai-mcp-manager' ), array( 'status' => 500 ) ); } if ( false === $result || null === $result ) { return new WP_Error( 'acrossai_mcp_quick_connect_install_failed', esc_html__( 'Installation failed. Try installing manually from Plugins → Add New.', 'acrossai-mcp-manager' ), array( 'status' => 500 ) ); } // Re-resolve: the plugin was not installed when we looked, so there // was no file to name. WordPress caches the plugin list, so clear it // or the freshly installed plugin stays invisible. wp_clean_plugins_cache(); $plugin_file = $this->find_installed_plugin_file( $slug ); } if ( null === $plugin_file ) { error_log( sprintf( '[acrossai-mcp-manager] could not resolve a plugin file for %s after install', $slug ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Deliberate diagnostics per file-header error-hygiene policy (internal log only, never surfaced to the client). return new WP_Error( 'acrossai_mcp_quick_connect_install_failed', esc_html__( 'Installation failed. Try installing manually from Plugins → Add New.', 'acrossai-mcp-manager' ), array( 'status' => 500 ) ); } if ( ! is_plugin_active( $plugin_file ) ) { $activate = activate_plugin( $plugin_file ); if ( is_wp_error( $activate ) ) { error_log( sprintf( '[acrossai-mcp-manager] activate_plugin failed for %s: %s', $plugin_file, $activate->get_error_message() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Deliberate diagnostics per file-header error-hygiene policy (internal log only, never surfaced to the client). return new WP_Error( 'acrossai_mcp_quick_connect_activate_failed', esc_html__( 'Activation failed. Try activating from Plugins.', 'acrossai-mcp-manager' ), array( 'status' => 500 ) ); } } return rest_ensure_response( array( 'installed' => true, 'active' => true, 'plugin' => $plugin_file, ) ); } // ───────────────────────────────────────────────────────────────────── // Per-step apply helpers // ───────────────────────────────────────────────────────────────────── /** * Step 1 — pick an existing server or signal "create new" intent. * * Accepts either: * - `server_id`: int → picks an existing row (Step 2 will be skipped) * - `create_intent`: true → user chose "+ Create a new server"; Step 2 * will render the create form and POST /step 2 with `new_server`. * * @param array $data Step payload. * @param array $scratchpad Current scratchpad. * @return array|WP_Error Updated scratchpad or error. */ private function apply_step_1( array $data, array $scratchpad ) { if ( ! empty( $data['create_intent'] ) ) { $scratchpad['create_intent'] = true; $scratchpad['server_id'] = null; // Clear any prior pick. return $scratchpad; } if ( ! isset( $data['server_id'] ) ) { return new WP_Error( 'acrossai_mcp_quick_connect_invalid_data', esc_html__( 'Choose a server to continue, or pick "Create a new server".', 'acrossai-mcp-manager' ), array( 'status' => 400 ) ); } $server_id = (int) $data['server_id']; if ( $server_id <= 0 ) { return new WP_Error( 'acrossai_mcp_quick_connect_invalid_data', esc_html__( 'Choose a server to continue.', 'acrossai-mcp-manager' ), array( 'status' => 400 ) ); } if ( ! $this->server_exists( $server_id ) ) { return new WP_Error( 'acrossai_mcp_quick_connect_server_gone', esc_html__( 'That server no longer exists. Restart the wizard.', 'acrossai-mcp-manager' ), array( 'status' => 410 ) ); } // Hiding an excluded server from the picker is presentation; this is // the boundary. The wizard accepts `?server=N` from the URL, so a // stale bookmark or a hand-built request could still name one that // was never offered. if ( NonConnectableServers::is_excluded_id( $server_id ) ) { return new WP_Error( 'acrossai_mcp_quick_connect_server_excluded', esc_html( NonConnectableServers::refusal_message() ), array( 'status' => 400 ) ); } $scratchpad['server_id'] = $server_id; $scratchpad['create_intent'] = false; return $scratchpad; } /** * Step 2 — create a new server row. Only reached when Step 1 signalled * `create_intent`. Requires `new_server` payload. * * @param array $data Step payload. * @param array $scratchpad Current scratchpad. * @return array|WP_Error Updated scratchpad or error. */ private function apply_step_2( array $data, array $scratchpad ) { if ( ! isset( $data['new_server'] ) || ! is_array( $data['new_server'] ) ) { return new WP_Error( 'acrossai_mcp_quick_connect_invalid_data', esc_html__( 'Fill in the new server form to continue.', 'acrossai-mcp-manager' ), array( 'status' => 400 ) ); } $sanitized = MCPServerFieldSanitizer::sanitize( $data['new_server'] ); if ( '' === $sanitized['server_name'] ) { return new WP_Error( 'acrossai_mcp_quick_connect_invalid_data', esc_html__( 'Server name is required.', 'acrossai-mcp-manager' ), array( 'status' => 400 ) ); } $query = MCPServerQuery::instance(); $existing = $query->query( array( 'server_slug' => $sanitized['server_slug'], 'number' => 1, ) ); if ( ! empty( $existing ) ) { return new WP_Error( 'acrossai_mcp_quick_connect_invalid_data', esc_html__( 'A server with that name already exists. Choose a different name.', 'acrossai-mcp-manager' ), array( 'status' => 400 ) ); } // Read from the wizard's `new_server` payload, where step 2's fields live. // Deliberately NOT routed through MCPServerFieldSanitizer: that helper // enforces a hard-coded 6-key whitelist as a B7 mass-assignment defence, // and widening it would weaken that guarantee for every caller. Validated // against the registry instead — an unregistered value degrades to the // registry default and never persists. $submitted_type = isset( $data['new_server']['server_type'] ) ? sanitize_key( (string) $data['new_server']['server_type'] ) : ''; $server_type = ( '' !== $submitted_type && null !== ServerTypes::get( $submitted_type ) ) ? $submitted_type : ServerTypes::default_slug(); $new_id = $query->add_item( array( 'server_name' => $sanitized['server_name'], 'server_slug' => $sanitized['server_slug'], 'description' => $sanitized['description'], 'is_enabled' => 0, 'registered_from' => 'database', 'server_route_namespace' => $sanitized['server_route_namespace'], 'server_route' => $sanitized['server_route'], 'server_version' => $sanitized['server_version'], // F090 (SEC-001) — the SECOND creation path. The first draft of // the plan listed only the classic admin form; the security // review found this one. Unwritten here, the row takes the // column default and the gate evaluates a type the operator // never chose. 'server_type' => $server_type, ) ); if ( ! $new_id ) { return new WP_Error( 'acrossai_mcp_quick_connect_persist_failed', esc_html__( 'Failed to create the server. Try again.', 'acrossai-mcp-manager' ), array( 'status' => 500 ) ); } // The SECOND creation path again — same reason the `server_type` write // above carries that note. A server created through the wizard needs // its type's tools as much as one created from the classic form, and // this is the path an operator is most likely to take. ToolPolicy::apply_type_defaults( (int) $new_id, $server_type ); $scratchpad['server_id'] = (int) $new_id; $scratchpad['create_intent'] = false; return $scratchpad; } /** * Step 5 — optionally bulk-enable all abilities for the wizard's server. * * F082 — "Enable all" now flips the SERVER-LEVEL default policy to * `'expose'`, exactly like the Abilities tab's Enable All button: * override rows are cleared (last-write-wins, spec F082 Q5) and future * ability registrations auto-inherit exposure with no admin action. * Replaces the pre-F082 one-upsert-per-ability loop, which wrote ~N * override rows and silently excluded any ability registered later. * * TASK-SEC-002 remediation still holds: the shared `PolicyTransition` * service calls the query layer directly — no internal REST-to-REST call * to the F017/F082 abilities controller. * * FR-015 parity: when the server is already on `'expose'`, this is a * no-op — overrides are NOT cleared and the policy-changed action does * NOT fire (same suppression as `AbilitiesController::post_policy()`). * * @param array $data Step payload. * @param array $scratchpad Current scratchpad. * @return array|WP_Error Updated scratchpad or error. */ private function apply_step_5( array $data, array $scratchpad ) { if ( empty( $data['enable_all_abilities'] ) ) { $scratchpad['abilities_saved'] = true; // "explicit skip" is a valid completion. return $scratchpad; } $server_id = isset( $scratchpad['server_id'] ) ? (int) $scratchpad['server_id'] : 0; if ( $server_id <= 0 ) { return new WP_Error( 'acrossai_mcp_quick_connect_invalid_data', esc_html__( 'Pick a server before enabling abilities.', 'acrossai-mcp-manager' ), array( 'status' => 400 ) ); } if ( ! function_exists( 'wp_get_abilities' ) ) { // Abilities API not present — nothing to enable; treat as saved. $scratchpad['abilities_saved'] = true; return $scratchpad; } // Shared F082 transition service (architecture-review R1): FR-015 // no-op suppression, override clearing, resolver-cache reset (this // SAME request pipes a fresh abilities summary into the save-step // response via collect_abilities_summary(), which must see the new // policy), and the `acrossai_mcp_server_policy_changed` fire with // the FR-011 was/now diff all live inside PolicyTransition::apply(). PolicyTransition::apply( $server_id, 'expose' ); $scratchpad['abilities_saved'] = true; return $scratchpad; } /** * Step 6 — enable the wizard's server. * * @param array $data Step payload. * @param array $scratchpad Current scratchpad. * @return array|WP_Error Updated scratchpad or error. */ private function apply_step_6( array $data, array $scratchpad ) { $server_id = isset( $scratchpad['server_id'] ) ? (int) $scratchpad['server_id'] : 0; if ( $server_id <= 0 ) { return new WP_Error( 'acrossai_mcp_quick_connect_invalid_data', esc_html__( 'Pick a server before enabling it.', 'acrossai-mcp-manager' ), array( 'status' => 400 ) ); } $enabled = ! empty( $data['enabled'] ); // F090 (ARCH-1): route through ServerEnablement, the sole sanctioned // writer of `is_enabled`. Quick Connect never touches the servers list // table, so a UI-only guard would not cover this path at all. $result = ServerEnablement::set( $server_id, $enabled ); if ( is_wp_error( $result ) ) { return $result; } // ServerEnablement returns its own WP_Error on a failed write, handled // above — no second persistence check is needed here. $scratchpad['enabled'] = $enabled; return $scratchpad; } // ───────────────────────────────────────────────────────────────────── // Collect helpers (state assembly) // ───────────────────────────────────────────────────────────────────── /** * Server list DTO for GET /state. * * @return array> */ private function collect_servers(): array { $rows = MCPServerQuery::instance()->query( array( 'orderby' => 'id', 'order' => 'ASC', ) ); $dtos = array(); foreach ( (array) $rows as $row ) { // Servers nothing connects to are not candidates for a wizard // whose whole job is producing connection details. See // QuickConnectExclusions for why the list lives there. if ( NonConnectableServers::is_excluded( isset( $row->server_slug ) ? (string) $row->server_slug : '' ) ) { continue; } $namespace = isset( $row->server_route_namespace ) ? (string) $row->server_route_namespace : 'mcp'; $route = isset( $row->server_route ) ? (string) $row->server_route : ''; $route_full = ltrim( $namespace, '/' ) . ( '' !== $route ? '/' . ltrim( $route, '/' ) : '' ); $dtos[] = array( 'id' => isset( $row->id ) ? (int) $row->id : 0, 'name' => isset( $row->server_name ) ? (string) $row->server_name : '', 'slug' => isset( $row->server_slug ) ? (string) $row->server_slug : '', 'route_namespace' => $namespace, 'route' => $route, 'route_full' => $route_full, 'enabled' => ! empty( $row->is_enabled ), // F090 (T038) — the wizard needs these so step 4 can refuse to be // skipped for a server that step 6 would then be unable to // enable. Discovering the requirement five steps in, at a dead // end, is the worst possible place to learn about it. 'server_type' => isset( $row->server_type ) ? (string) $row->server_type : '', 'type_available' => ServerTypes::is_available( isset( $row->server_type ) ? (string) $row->server_type : '' ), ); } return $dtos; } /** * Abilities summary for GET /state. * * When a `$server_id` is passed, also returns `enabledForServer` — the * number of abilities currently exposed on that server per the F017 * canonical resolver (row override → meta.mcp.public fallback). Null when * no server is chosen yet so the frontend can distinguish "not-computed" * from "zero enabled". * * @param int|null $server_id Server id from the wizard scratchpad, if any. * @return array{total:int,enabledForServer:int|null,hasManagerPlugin:bool} */ private function collect_abilities_summary( ?int $server_id = null ): array { $has_abilities_api = function_exists( 'wp_get_abilities' ); $total = $has_abilities_api ? count( \wp_get_abilities() ) : 0; $enabled_for_server = null; if ( $has_abilities_api && null !== $server_id && $server_id > 0 ) { $enabled_for_server = 0; foreach ( \wp_get_abilities() as $ability ) { $slug = $ability->get_name(); if ( ! is_string( $slug ) || '' === $slug ) { continue; } $meta = $ability->get_meta(); if ( MCPServerAbilityExposureResolver::resolve_effective( $server_id, $slug, is_array( $meta ) ? $meta : array() ) ) { ++$enabled_for_server; } } } return array( 'total' => $total, 'enabledForServer' => $enabled_for_server, 'hasManagerPlugin' => 'active' === $this->plugin_activation_state( 'acrossai-abilities-manager/acrossai-abilities-manager.php' ), ); } /** * Plugin activation state map for GET /state. * * When a plugin is installed-but-inactive, an accompanying `*ActivateUrl` * field carries the nonced `plugins.php?action=activate&...` URL so the * frontend can render a one-click activate button. The URL is null (or * absent) for `missing` and `active` states. * * @return array */ private function collect_plugin_states(): array { // F095 — no Pro state is reported any more. Connectors is a free // capability, so the wizard has nothing to gate on and the steps that // consumed `acrossaiPro`, `acrossaiProLicensed` and `trialEndDate` // are gone. The abilities-manager entries stay: that plugin is still a // genuine optional dependency. $manager_file = 'acrossai-abilities-manager/acrossai-abilities-manager.php'; $manager_state = $this->plugin_activation_state( $manager_file ); return array( 'abilitiesManager' => $manager_state, 'abilitiesManagerActivateUrl' => 'inactive' === $manager_state ? $this->plugin_activate_url( $manager_file ) : null, ); } /** * Build a nonced `plugins.php?action=activate&plugin=...` URL. * * @param string $plugin_file Relative plugin file (e.g. `foo/foo.php`). * @return string Absolute admin URL with the activate-plugin nonce. */ private function plugin_activate_url( string $plugin_file ): string { $url = admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( $plugin_file ) ); return wp_nonce_url( $url, 'activate-plugin_' . $plugin_file ); } /** * Resolve one plugin's activation state to `'missing'|'inactive'|'active'`. * Mirrors the F040 promo card's tri-state semantics. * * @param string $plugin_file Relative plugin file (e.g. `foo/foo.php`). * @return string */ private function plugin_activation_state( string $plugin_file ): string { if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( is_plugin_active( $plugin_file ) ) { return 'active'; } if ( function_exists( 'get_plugins' ) ) { $installed = get_plugins(); if ( isset( $installed[ $plugin_file ] ) ) { return 'inactive'; } } return 'missing'; } // ───────────────────────────────────────────────────────────────────── // Scratchpad + helpers // ───────────────────────────────────────────────────────────────────── /** * Default scratchpad shape when no transient exists yet. * * @return array */ private function default_scratchpad(): array { return array( 'current_step' => 1, 'server_id' => null, 'create_intent' => false, 'access_saved' => false, 'abilities_saved' => false, 'enabled' => false, 'method' => null, 'created_at' => time(), ); } /** * Read the current user's scratchpad, defaulting when missing. * * @return array */ private function read_scratchpad(): array { $stored = get_transient( self::SCRATCHPAD_KEY_PREFIX . get_current_user_id() ); if ( ! is_array( $stored ) ) { return $this->default_scratchpad(); } return array_merge( $this->default_scratchpad(), $stored ); } /** * Persist the current user's scratchpad with a fresh TTL. * * WP core caveat: `set_transient()` delegates to `update_option()` when * no external object cache is configured, and `update_option()` returns * false when the new value is identical to the stored value — even * though the write conceptually succeeded (the store already holds what * we wanted). Naively returning that false would surface a spurious * "Failed to save your progress" error to the user any time a wizard * step's payload doesn't alter the scratchpad (e.g. re-clicking * "Enable all and continue" after all abilities are already enabled). * * Fix: when the raw call returns false, read the transient back and * treat "stored value already matches what we tried to write" as * success. Real store failures (disk full, memcached down) will fail * both the write AND the read-back and correctly return false. * * @param array $data Full scratchpad shape. * @return bool True on success, false on failure. */ private function write_scratchpad( array $data ): bool { $key = self::SCRATCHPAD_KEY_PREFIX . get_current_user_id(); if ( set_transient( $key, $data, self::SCRATCHPAD_TTL ) ) { return true; } $stored = get_transient( $key ); // Strict === on arrays checks type + key order + values. Transient // serialization (PHP serialize/unserialize) preserves key order, so // stored === data holds when the payload is truly identical. return is_array( $stored ) && $stored === $data; } /** * Check whether the given server row still exists. * * @param int $server_id Server row id. * @return bool */ private function server_exists( int $server_id ): bool { $rows = MCPServerQuery::instance()->query( array( 'id' => $server_id, 'number' => 1, ) ); return ! empty( $rows ); } }