*/ private $bulk_skipped = array(); /** @var Settings|null */ protected static $_instance = null; /** @var string */ private $plugin_name; /** @var string */ private $version; /** * Return the singleton instance. * * @since 0.1.0 * @return self */ public static function instance(): self { if ( null === self::$_instance ) { self::$_instance = new self(); } return self::$_instance; } /** * Private constructor — use instance(). * * @since 0.1.0 */ private function __construct() { $this->plugin_name = ACROSSAI_MCP_MANAGER_PLUGIN_NAME_SLUG; $this->version = ACROSSAI_MCP_MANAGER_VERSION; // NO add_action / add_filter — wired by Includes\Main::define_admin_hooks(). } // ───────────────────────────────────────────────────────────────────────── // Action dispatcher (US2 + partial US3) — wired on admin_init priority 5. // ───────────────────────────────────────────────────────────────────────── /** * Auto-heal + reconcile every plugin-managed MCP server row. * * DefaultServerSeeder::seed() is idempotent — it inserts only when a * managed slug is absent, and otherwise rewrites just the managed columns * that have drifted. Running it on admin_init means the rows self-restore * after a manual delete or bulk-delete, AND that a plugin UPDATE which * adds a column or changes a managed value rolls out on the next admin * page load without requiring reactivation (activation fires once only). * * Runs at priority 4, after Main::reconcile_database_schemas() at * priority 3 — so the DDL for any newly added column is already applied * by the time the seeder writes to it. * * Method name kept from F011 (singular "default server") for hook-wiring * stability; since F088 it owns all managed rows. * * @return void */ public function maybe_seed_default_server(): void { DefaultServerSeeder::seed(); } /** * Route plugin-page actions to the right handler. FR-007 / FR-007a / FR-013. * * NB: Nonce verification happens inside each per-action branch (the page * + action gate has no nonce of its own). * * @return void */ public function handle_actions(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( ! isset( $_GET['page'] ) || AdminPageSlugs::PARENT !== sanitize_text_field( wp_unslash( $_GET['page'] ) ) ) { return; } $action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended // US2: toggle_status, delete (single), create (POST), bulk. // US3: update (General-tab save). // F015 (post-Q4): access-control saves are owned by the vendor React // component via vendor REST — no plugin-owned action handler for the // wpb-ac panel. // F030: save_permission_override handles the per-server override toggle // form on the Access Control tab (does NOT touch the wpb-ac panel). // Fleet: fleet_new_security_id reissues the code a managing site needs, // fleet_remove_host cuts one off. Joining is not an action here — the // managing site calls this site's registration endpoint instead. if ( ! in_array( $action, array( 'toggle_status', 'delete', 'create', 'update', 'save_permission_override', 'save_instructions', 'fleet_new_security_id', 'fleet_remove_host' ), true ) && ! $this->is_bulk_request() ) { return; } if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'acrossai-mcp-manager' ) ); } // ── Single-row toggle_status ────────────────────────────────────────── if ( 'toggle_status' === $action ) { $server_id = isset( $_GET['server'] ) ? absint( $_GET['server'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification check_admin_referer( 'acrossai_mcp_toggle_' . $server_id ); if ( $server_id > 0 ) { $this->toggle_server_status( $server_id ); } // `redirect_to=edit` is set by OverviewTab so the toggle button // on the server-edit page returns the user to the edit page // (overview tab) instead of the list. $redirect_to = isset( $_GET['redirect_to'] ) ? sanitize_key( wp_unslash( $_GET['redirect_to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification if ( 'edit' === $redirect_to && $server_id > 0 ) { $this->redirect_to_edit( $server_id, 'overview', 'server_toggled' ); } $this->redirect_to_list( 'server_toggled' ); } // ── Fleet: replace the security ID ──────────────────────────────────── // // There is no "add host" action here any more. A managing site connects // by calling this site's registration endpoint with the security ID // below, so the only thing this screen does is issue and replace that // ID. Nothing is pasted in, and this site never calls out. if ( 'fleet_new_security_id' === $action ) { $server_id = isset( $_GET['server'] ) ? absint( $_GET['server'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification check_admin_referer( 'acrossai_mcp_fleet_newid_' . $server_id ); if ( $server_id > 0 ) { \AcrossAI_MCP_Manager\Includes\Fleet\SecurityId::regenerate( $server_id ); } $this->redirect_to_edit( $server_id, 'overview', 'fleet_new_id' ); } // ── Fleet: leave a fleet ────────────────────────────────────────────── if ( 'fleet_remove_host' === $action ) { $server_id = isset( $_GET['server'] ) ? absint( $_GET['server'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification $host_key = isset( $_GET['host'] ) ? sanitize_key( wp_unslash( $_GET['host'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification check_admin_referer( 'acrossai_mcp_fleet_remove_' . $server_id . '_' . $host_key ); // Removing the record IS the revocation: the host's access was // that stored public key, so once it is gone its signatures // verify against nothing. There is no credential left behind to // clean up, which is the point of the signature design. $removed = \AcrossAI_MCP_Manager\Includes\Fleet\Hosts::remove( $server_id, $host_key ); if ( ! $removed ) { $this->redirect_to_edit_with_detail( $server_id, 'fleet_remove_failed', __( 'That host is already gone.', 'acrossai-mcp-manager' ) ); } $this->redirect_to_edit( $server_id, 'overview', 'fleet_removed' ); } // ── Single-row delete ───────────────────────────────────────────────── if ( 'delete' === $action ) { $server_id = isset( $_GET['server'] ) ? absint( $_GET['server'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification check_admin_referer( 'acrossai_mcp_delete_' . $server_id ); // F088 — plugin-managed rows are seeder-owned; deleting one just // makes it reappear on the next admin request. Refuse instead of // churning the table. The UI hides the affordance; this closes the // hand-crafted-URL path. if ( ProtectedServers::is_protected_id( $server_id ) ) { $this->redirect_to_list( 'server_protected' ); } if ( $server_id > 0 ) { Query::instance()->delete_item( $server_id ); /** * Fires after a server row is deleted from the primary table. * Subscribers (F037 embed-transport cleanup, future modules) * hook this to prune per-server rows in their own tables. * * @since Feature 037 * * @param int $server_id Deleted server primary key. */ do_action( 'acrossai_mcp_server_deleted', $server_id ); } $this->redirect_to_list( 'server_deleted' ); } // ── Bulk action (enable / disable / delete) ────────────────────────── if ( $this->is_bulk_request() ) { check_admin_referer( 'bulk-mcp_servers' ); $this->handle_bulk_actions(); $this->redirect_to_list( 'bulk_completed' ); } // ── Create (POST) ───────────────────────────────────────────────────── if ( 'create' === $action && $this->is_post_request() ) { check_admin_referer( 'acrossai_mcp_create_server' ); $this->handle_create_server(); } // ── Update (General-tab save, POST) — US3 / FR-009 / FR-013 ─────────── if ( 'update' === $action && $this->is_post_request() ) { $server_id = isset( $_GET['server'] ) ? absint( $_GET['server'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification check_admin_referer( 'acrossai_mcp_update_' . $server_id ); $this->handle_update_server( $server_id ); } // ── F030 — save_permission_override (POST) ──────────────────────────── // Access Control tab's per-server override toggle. Owns its own save // path so `handle_update_server()` (Update Server tab) is never // coupled to Access Control state. if ( 'save_permission_override' === $action && $this->is_post_request() ) { $server_id = isset( $_GET['server'] ) ? absint( $_GET['server'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification check_admin_referer( 'acrossai_mcp_manager_permission_override_' . $server_id, 'acrossai_mcp_manager_permission_override_nonce' ); $this->handle_save_permission_override( $server_id ); } // ── 0.3.6 — save_instructions (POST) ───────────────────────────────── // Overview tab's connect-message override. Its own save path for the // same reason as the one above: the Update Server tab's handler owns // the server's identity columns and must not grow a second concern. if ( 'save_instructions' === $action && $this->is_post_request() ) { $server_id = isset( $_GET['server'] ) ? absint( $_GET['server'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification check_admin_referer( 'acrossai_mcp_manager_instructions_' . $server_id, 'acrossai_mcp_manager_instructions_nonce' ); $this->handle_save_instructions( $server_id ); } // Embed settings are saved by acrossai-pro's Embeds tab, which owns // the REST controller on this plugin's namespace // (`POST /acrossai-mcp-manager/v1/servers/{server_id}/embeds`). // There is no admin-post path here. // using the WP core `wp_rest` nonce. // F015 note (post-Q4): access-control saves for the vendor wpb-ac // React panel are owned by vendor REST endpoints (PUT/DELETE // /wpb-ac/v1/mcp/rules/{ns}/{key}). No plugin-owned POST handler // for the vendor panel here. } /** * Toggle a server row's enabled state. Two-step per research.md R1: * read current value, flip, update. * * @param int $server_id Server row id to toggle. */ private function toggle_server_status( int $server_id ): void { $query = Query::instance(); $rows = $query->query( array( 'id' => $server_id, 'number' => 1, ) ); if ( empty( $rows ) ) { return; } $current_enabled = (int) $rows[0]->is_enabled; // F090 (ARCH-1): every enable/disable goes through ServerEnablement, the // sole sanctioned writer of `is_enabled`. It enforces the server type's // requirement on off -> on and always permits on -> off. $result = ServerEnablement::set( $server_id, 1 !== $current_enabled ); if ( is_wp_error( $result ) ) { // POST-redirect-GET: the list page re-renders on a fresh request, so // an instance property would never reach the operator. Carry the // refusal as a notice key, matching every other action here. $this->redirect_to_list( 'type_unavailable' ); } } /** * Detect a bulk-list-submit request. WP_List_Table puts the chosen action * in `action` OR `action2` (top vs bottom dropdown) and serialised row * IDs in `server_ids[]`. */ private function is_bulk_request(): bool { // phpcs:disable WordPress.Security.NonceVerification.Recommended $action1 = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; $action2 = isset( $_REQUEST['action2'] ) ? sanitize_key( wp_unslash( $_REQUEST['action2'] ) ) : ''; $bulk = in_array( $action1, array( 'enable', 'disable', 'delete' ), true ) || in_array( $action2, array( 'enable', 'disable', 'delete' ), true ); $has_ids = isset( $_REQUEST['server_ids'] ) && is_array( $_REQUEST['server_ids'] ); // phpcs:enable WordPress.Security.NonceVerification.Recommended return $bulk && $has_ids; } /** * Whether the current request is an HTTP POST. * * @since 0.1.0 * @return bool */ private function is_post_request(): bool { return isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ); } /** * Apply enable / disable / delete to each selected row. FR-006 / FR-007. * Caller verified the `bulk-mcp_servers` nonce and the capability. */ private function handle_bulk_actions(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended $action1 = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; $action2 = isset( $_REQUEST['action2'] ) ? sanitize_key( wp_unslash( $_REQUEST['action2'] ) ) : ''; $action = in_array( $action1, array( 'enable', 'disable', 'delete' ), true ) ? $action1 : $action2; $ids = isset( $_REQUEST['server_ids'] ) && is_array( $_REQUEST['server_ids'] ) ? array_map( 'absint', wp_unslash( $_REQUEST['server_ids'] ) ) : array(); // phpcs:enable WordPress.Security.NonceVerification.Recommended $query = Query::instance(); foreach ( $ids as $id ) { if ( $id <= 0 ) { continue; } if ( 'enable' === $action || 'disable' === $action ) { // F090 (ARCH-1 + FR-016a): PARTIAL SUCCESS. Route through the // facade so an ineligible row is skipped rather than failing the // whole action, and collect its reason so the operator is told // which rows were skipped and why — never silently. $result = ServerEnablement::set( $id, 'enable' === $action ); if ( is_wp_error( $result ) ) { $this->bulk_skipped[ $id ] = $result->get_error_message(); } } elseif ( 'delete' === $action ) { // F088 — never bulk-delete a plugin-managed row (the list // table renders no checkbox for them, so this only fires on a // forged request). Enable/Disable stay allowed. if ( ProtectedServers::is_protected_id( $id ) ) { continue; } $query->delete_item( $id ); } } // F090 (FR-016a) — partial success must be VISIBLE. Eligible rows were // switched above; if any were skipped the operator is told, rather than // the action appearing to have fully succeeded. if ( ! empty( $this->bulk_skipped ) ) { $this->redirect_to_list( 'bulk_partial' ); } } /** * Create-form handler. FR-007a. Caller already verified the nonce + cap. * * Sanitization delegated to the shared MCPServerFieldSanitizer helper * (F069 / TASK-SEC-001) so this admin form + the Quick Connect via AcrossAI wizard's * REST controller apply identical validation with a hard-coded 6-key * whitelist defence against B7 mass-assignment via forged POST keys. */ private function handle_create_server(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- check_admin_referer( 'acrossai_mcp_create_server' ) ran in handle_actions() before dispatch. $sanitized = MCPServerFieldSanitizer::sanitize_from_post( $_POST ); // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing $name = $sanitized['server_name']; $description = $sanitized['description']; $namespace = $sanitized['server_route_namespace']; $route = $sanitized['server_route']; $version = $sanitized['server_version']; $slug = $sanitized['server_slug']; if ( '' === $name ) { $this->redirect_to_create( 'empty_name' ); } $query = Query::instance(); // Slug collision check via Query — R1 mapping. $existing = $query->query( array( 'server_slug' => $slug, 'number' => 1, ) ); if ( ! empty( $existing ) ) { $this->redirect_to_create( 'slug_exists' ); } // Route falls back to slug if the sanitizer left it empty (matches // pre-F069 behavior — sanitizer preserves user's explicit route // choice; empty route → slug alias). if ( '' === $route ) { $route = $slug; } // F090 (SEC-001) — write server_type EXPLICITLY. A row that falls through // to the column default takes the legacy type rather than the operator's // selection, and the enablement gate then evaluates a type they never // chose. Validated against the registry: an unregistered value (forged // POST, stale form) degrades to the registry default, never persists. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check_admin_referer( 'acrossai_mcp_create_server' ) ran in handle_actions() before dispatch. $submitted_type = isset( $_POST['server_type'] ) ? sanitize_key( wp_unslash( $_POST['server_type'] ) ) : ''; $server_type = ( '' !== $submitted_type && null !== ServerTypes::get( $submitted_type ) ) ? $submitted_type : ServerTypes::default_slug(); $new_id = $query->add_item( array( 'server_name' => $name, 'server_slug' => $slug, 'description' => $description, 'is_enabled' => 0, 'registered_from' => 'database', 'server_route_namespace' => $namespace, 'server_route' => $route, 'server_version' => $version, 'server_type' => $server_type, ) ); if ( ! $new_id ) { $this->redirect_to_create( 'db_error' ); } // Give the server the tools its TYPE declares. Without this it carries // whatever the `tool_*` column defaults give it — the three protocol // tools — whichever type was chosen, so an AcrossAI server arrived with // mcp-adapter's set and an mcp-adapter one arrived without its guide. ToolPolicy::apply_type_defaults( (int) $new_id, $server_type ); wp_safe_redirect( esc_url_raw( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'edit', 'server' => $new_id, 'notice' => 'server_created', ), admin_url( 'admin.php' ) ) ) ); exit; } /** * Redirect back to the server list with a notice code, then exit. * * @since 0.1.0 * @param string $notice Notice code appended as a query arg. */ private function redirect_to_list( string $notice ): void { wp_safe_redirect( esc_url_raw( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'notice' => $notice, ), admin_url( 'admin.php' ) ) ) ); exit; } /** * Redirect back to the create-server form with a notice code, then exit. * * @since 0.1.0 * @param string $notice Notice code appended as a query arg. */ private function redirect_to_create( string $notice ): void { wp_safe_redirect( esc_url_raw( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'create', 'notice' => $notice, ), admin_url( 'admin.php' ) ) ) ); exit; } /** * Redirect back to a server's Overview carrying a notice AND its reason. * * The sibling below takes a notice KEY, which `Notices` maps to fixed * wording. That is right when there is one way to fail; pairing has many, * and most of the useful detail is only known at the moment it happens — * which host was unreachable, what the managing site said when it refused. * Flattening those into one catalogue entry would throw away the only part * the operator can act on. * * The detail is the plugin's own message or a remote site's, so it is * carried URL-encoded and escaped again at the render site in `Notices`. * * @since 0.4.6 * * @param int $server_id Server row id. * @param string $notice Notice key. * @param string $detail Human-readable reason. */ private function redirect_to_edit_with_detail( int $server_id, string $notice, string $detail ): void { wp_safe_redirect( esc_url_raw( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'edit', 'server' => $server_id, 'tab' => 'overview', 'notice' => $notice, 'notice_detail' => rawurlencode( $detail ), ), admin_url( 'admin.php' ) ) ) ); exit; } /** * Redirect back to a server's edit screen with a notice code, then exit. * * @since 0.1.0 * @param int $server_id Server row id. * @param string $tab Edit-screen tab slug to land on. * @param string $notice Notice code appended as a query arg. */ private function redirect_to_edit( int $server_id, string $tab, string $notice ): void { wp_safe_redirect( esc_url_raw( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'edit', 'server' => $server_id, 'tab' => $tab, 'notice' => $notice, ), admin_url( 'admin.php' ) ) ) ); exit; } /** * General-tab save handler. FR-009 / FR-013. Caller verified nonce + cap. * * @param int $server_id Server row id being updated. */ private function handle_update_server( int $server_id ): void { $query = Query::instance(); $rows = $query->query( array( 'id' => $server_id, 'number' => 1, ) ); if ( empty( $rows ) ) { $this->redirect_to_list( 'server_not_found' ); } // F088 — the Update Server tab is hidden for plugin-managed rows, but // the POST target is still reachable; DefaultServerSeeder would revert // any write here on the next admin request anyway. if ( ProtectedServers::is_protected( (string) $rows[0]->server_slug ) ) { $this->redirect_to_edit( $server_id, 'overview', 'server_protected' ); } // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- check_admin_referer( 'acrossai_mcp_update_' . $server_id ) ran in handle_actions() before dispatch. $data = array( 'server_name' => isset( $_POST['server_name'] ) ? sanitize_text_field( wp_unslash( $_POST['server_name'] ) ) : '', 'description' => isset( $_POST['description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['description'] ) ) : '', 'server_route_namespace' => isset( $_POST['server_route_namespace'] ) ? sanitize_text_field( wp_unslash( $_POST['server_route_namespace'] ) ) : 'mcp', 'server_route' => isset( $_POST['server_route'] ) ? sanitize_text_field( wp_unslash( $_POST['server_route'] ) ) : '', 'server_version' => isset( $_POST['server_version'] ) ? sanitize_text_field( wp_unslash( $_POST['server_version'] ) ) : 'v1.0.0', ); // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing if ( '' === $data['server_name'] ) { $this->redirect_to_edit( $server_id, 'update-server', 'empty_name' ); } if ( '' === $data['server_route'] ) { $data['server_route'] = $rows[0]->server_slug; } $query->update_item( $server_id, $data ); $this->redirect_to_edit( $server_id, 'update-server', 'server_saved' ); } /** * F030 — save handler for the per-server ability permission_callback * override toggle. Caller (`handle_actions()`) already verified nonce + * that this is a POST request. * * Enforces `manage_options` capability defensively (belt-and-suspenders * atop the top-level check in `handle_actions()`). Persists the tinyint * flag via `MCPServerQuery::update_item()`. Fires the * `acrossai_mcp_permission_override_toggled` D19-style observability * hook so operators can attach an audit logger without a hard dep * (SEC-030-002 remediation). * * Redirects back to the Access Control tab with a success flag; the tab * renders the notice via `AccessControlTab::render_save_notice()`. * * @param int $server_id MCP server PK. * @return void */ private function handle_save_permission_override( int $server_id ): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'acrossai-mcp-manager' ), '', array( 'response' => 403 ) ); } if ( $server_id <= 0 ) { $this->redirect_to_list( 'server_not_found' ); } $query = Query::instance(); $rows = $query->query( array( 'id' => $server_id, 'number' => 1, ) ); if ( empty( $rows ) ) { $this->redirect_to_list( 'server_not_found' ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in handle_actions() dispatcher via check_admin_referer(). $value = ! empty( $_POST['override_abilities_permission'] ) ? 1 : 0; $query->update_item( $server_id, array( 'override_abilities_permission' => $value ) ); /** * Fires after the operator toggles the per-server permission override. * * D19-style fail-open observability signal. Fire-and-forget — return * value is ignored. Operators can attach any logger (Query Monitor, * custom audit table, syslog) without this plugin depending on it. * * @since Feature 030 * * @param int $server_id MCP server PK. * @param int $value New value (0 = off, 1 = on). * @param int $user_id ID of the user who made the change. * @param int $timestamp Unix timestamp of the change. */ do_action( 'acrossai_mcp_permission_override_toggled', $server_id, $value, get_current_user_id(), time() ); wp_safe_redirect( esc_url_raw( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'edit', 'server' => $server_id, 'tab' => 'access-control', 'acrossai_mcp_manager_permission_saved' => 1, ), admin_url( 'admin.php' ) ) ) ); exit; } /** * Save the per-server connect-message override. * * Two states, not three. "System default" DELETES the row rather than * storing a copy of the default: a stored copy would keep the wording the * operator saw on the day they saved it, and silently stop tracking the * plugin when that wording improves. * * Choosing "Custom" and leaving the box empty is treated as System default * and the field says so. The alternative is a third state meaning "send my * description and nothing else", which costs an explanation on every screen * to serve a case nobody has asked for — and `MCPServerMeta\Query` does not * round-trip an empty value anyway. * * @since 0.3.6 * @param int $server_id MCP server PK. * @return void */ private function handle_save_instructions( int $server_id ): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'acrossai-mcp-manager' ), '', array( 'response' => 403 ) ); } if ( $server_id <= 0 ) { $this->redirect_to_list( 'server_not_found' ); } $rows = Query::instance()->query( array( 'id' => $server_id, 'number' => 1, ) ); if ( empty( $rows ) ) { $this->redirect_to_list( 'server_not_found' ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in handle_actions() dispatcher via check_admin_referer(). $mode = isset( $_POST['instructions_mode'] ) ? sanitize_key( wp_unslash( $_POST['instructions_mode'] ) ) : 'default'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- as above. $custom = isset( $_POST['instructions_custom'] ) ? trim( (string) wp_unslash( $_POST['instructions_custom'] ) ) : ''; // `sanitize_textarea_field` and not `wp_kses_post`: this is sent to an // AI client as plain text, never rendered as HTML, so markup would be // noise at best and instructions smuggled into a model at worst. $custom = sanitize_textarea_field( $custom ); if ( 'custom' === $mode && '' !== $custom ) { MCPServerMetaQuery::update_meta( $server_id, MCPController::INSTRUCTIONS_META_KEY, $custom ); } else { MCPServerMetaQuery::delete_meta( $server_id, MCPController::INSTRUCTIONS_META_KEY ); } wp_safe_redirect( esc_url_raw( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'edit', 'server' => $server_id, 'tab' => 'overview', 'acrossai_mcp_manager_instructions_saved' => 1, ), admin_url( 'admin.php' ) ) ) ); exit; } // Embed settings have no save handler here. acrossai-pro's Embeds tab // extends AbstractReactMountServerTab, which auto-registers a REST // controller (POST /acrossai-mcp-manager/v1/servers/{server_id}/embeds) // on this plugin's namespace and does the writing itself. Server scoping // comes from the route's `{server_id}` parameter plus the base class's // rest_permission_callback. // same admin cap gate. // ───────────────────────────────────────────────────────────────────────── // Page render — wired as the menu callback by Menu::register_menu(). // ───────────────────────────────────────────────────────────────────────── /** * Dispatcher: route to list / create / edit / quick-connect based on * the `action` + `quick-connect` query vars. * * F069 T017 — When `?quick-connect=1` is present, hijack the render BEFORE * any list-table or edit-page logic runs and hand off to QuickConnectPage. * The check lives here (not in a filter) because the parent-menu render * callback is registered via `add_menu_page`; intercepting at any later * point risks partial-render bleed-through. */ public function render_list_page(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'acrossai-mcp-manager' ) ); } // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( ! empty( $_GET['quick-connect'] ) && '1' === (string) $_GET['quick-connect'] ) { QuickConnect\QuickConnectPage::instance()->render(); return; } $action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( 'create' === $action ) { $this->render_create_form(); return; } if ( 'edit' === $action ) { $this->render_edit_page(); return; } $this->render_servers_table(); } /** * Render the MCP servers WP_List_Table (pre-approved constitution exception). * * @since 0.1.0 */ private function render_servers_table(): void { $table = new MCPServerListTable(); $table->prepare_items(); $create_url = esc_url( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'create', ), admin_url( 'admin.php' ) ) ); $quick_connect_url = esc_url( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'quick-connect' => '1', 'step' => '1', ), admin_url( 'admin.php' ) ) ); echo '
'; printf( '

%s

%s %s
', esc_html__( 'MCP Servers', 'acrossai-mcp-manager' ), esc_url( $create_url ), // SEC-S2: defense in depth — esc_url is idempotent. esc_html__( 'Add New', 'acrossai-mcp-manager' ), esc_url( $quick_connect_url ), esc_html__( 'Connect via AcrossAI', 'acrossai-mcp-manager' ) ); echo '
'; // Required nonce for bulk actions — WP_List_Table::display() expects `bulk-{plural}` nonce. wp_nonce_field( 'bulk-mcp_servers' ); $table->display(); echo '
'; echo '
'; } /** * "Add New" create form. Submits POST to ?page=...&action=create which is * handled by Settings::handle_actions → handle_create_server(). */ private function render_create_form(): void { $post_url = esc_url( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'create', ), admin_url( 'admin.php' ) ) ); ?>

1 ) : ?>
* Tabs: general (default), tokens, access_control. */ private function render_edit_page(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended $server_id = isset( $_GET['server'] ) ? absint( $_GET['server'] ) : 0; $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'overview'; // phpcs:enable WordPress.Security.NonceVerification.Recommended // Feature 013 — legacy tab slug back-compat (pre-F013 bookmarks/links). // Feature 084 — the five connection tabs merged into ConnectTab; their // old slugs now resolve to it. Which METHOD opens is decided by // ConnectTab::resolve_active_method(), which reads the PRE-REWRITE // ?tab= value against the same ConnectTab::LEGACY_TAB_METHODS constant. // One constant, two readers, no duplication. // // Rewritten IN PLACE, never via a redirect: admin_enqueue_scripts has // already fired by the time this runs, and the acrossai-pro companion // gates its assets on the address the browser actually requested. // Redirecting would rewrite the address before those gates saw it and // leave its panels unstyled with inert buttons (FR-009). $legacy_slug_map = array( 'general' => 'overview', 'access_control' => 'access-control', ); foreach ( array_keys( ConnectTab::LEGACY_TAB_METHODS ) as $legacy_connection_slug ) { $legacy_slug_map[ $legacy_connection_slug ] = 'connect'; } if ( isset( $legacy_slug_map[ $tab ] ) ) { $tab = $legacy_slug_map[ $tab ]; } // FR-014: missing-server → redirect to list. $rows = Query::instance()->query( array( 'id' => $server_id, 'number' => 1, ) ); if ( empty( $rows ) ) { $this->redirect_to_list( 'server_not_found' ); } $row = $rows[0]; $server = $row->to_array(); // Feature 013 — Registry dispatches per-tab render + supplies visible tab list to the nav. $registry = \AcrossAI_MCP_Manager\Admin\Partials\ServerTabs\Registry::instance(); $tabs = array(); foreach ( $registry->visible_tabs( $server ) as $tab_obj ) { $tabs[ $tab_obj->slug() ] = $tab_obj->label(); } if ( ! array_key_exists( $tab, $tabs ) ) { // The FIRST VISIBLE tab, not the literal 'overview'. A fleet // server has no Overview tab at all — `FleetTab` replaces it // rather than sitting beside it — so hardcoding that slug sent // every unrecognised tab request there to render a tab the nav // does not list and `visible_tabs()` excluded on purpose. // `$tabs` is already in priority order, so this is 'overview' // for every other server exactly as before. $tab = (string) array_key_first( $tabs ); } echo '
'; printf( '

%s — %s

', esc_html__( 'Edit MCP Server', 'acrossai-mcp-manager' ), esc_html( $row->server_name ) ); SettingsRenderer::instance()->render_tab_nav( $tabs, $tab, $server_id ); $registry->render( $tab, $server ); echo '
'; } }