'mcp_server', 'plural' => 'mcp_servers', 'ajax' => false, ) ); } /** * Columns: Name, Status, Registered From, Route, Actions. * Plus a `cb` checkbox column to enable bulk actions. * * Slug column removed — the URL/route columns already carry the same * identity signal and the slug is exposed inline on the Name column edit * link. `route_namespace` + `route` merged into a single `Route` column * displayed as `/`. `version` column removed — * uncommonly referenced from the list view; still surfaced on the edit * page. Actions column bundles Edit + Enable/Disable toggle + quick- * access links into the per-server-edit tabs (Connectors, Access Control, * Abilities, MCP Clients). */ public function get_columns(): array { return array( 'cb' => '', 'name' => esc_html__( 'Name', 'acrossai-mcp-manager' ), 'status' => esc_html__( 'Status', 'acrossai-mcp-manager' ), 'source' => esc_html__( 'Registered From', 'acrossai-mcp-manager' ), 'route' => esc_html__( 'Route', 'acrossai-mcp-manager' ), 'actions' => esc_html__( 'Actions', 'acrossai-mcp-manager' ), ); } /** * Bulk actions per FR-006. Each handler in Settings::handle_actions * verifies the WP-Lists nonce action `bulk-mcp_servers`. */ public function get_bulk_actions(): array { return array( 'enable' => esc_html__( 'Enable', 'acrossai-mcp-manager' ), 'disable' => esc_html__( 'Disable', 'acrossai-mcp-manager' ), 'delete' => esc_html__( 'Delete', 'acrossai-mcp-manager' ), ); } /** * Pull rows via the BerlinDB-style Query class. FR-005 + FR-022. */ public function prepare_items(): void { $this->_column_headers = array( $this->get_columns(), array(), array() ); $rows = Query::instance()->query( array( 'orderby' => 'id', 'order' => 'ASC', ) ); $this->items = array_map( static function ( $row ) { return array( 'id' => $row->id, 'name' => $row->server_name, 'slug' => $row->server_slug, 'description' => $row->description, 'enabled' => ! empty( $row->is_enabled ), 'registered_from' => $row->registered_from, 'server_route_namespace' => $row->server_route_namespace, 'server_route' => $row->server_route, 'server_version' => $row->server_version, // F090 — the Enable affordance is disabled when this type's // requirement is unmet, so the row must carry the type. 'server_type' => $row->server_type, ); }, $rows ); // Pin the Recommended plugin-managed server to the top of the list; // every other row keeps the insertion order the query already asked // for (`id ASC` above), which is also this sort's own tiebreak. usort( $this->items, static function ( $a, $b ) { $rank_a = ProtectedServers::is_recommended( (string) $a['slug'] ) ? 0 : 1; $rank_b = ProtectedServers::is_recommended( (string) $b['slug'] ) ? 0 : 1; return ( $rank_a === $rank_b ) ? ( (int) $a['id'] <=> (int) $b['id'] ) : ( $rank_a <=> $rank_b ); } ); } /** * Row checkbox for bulk actions. * * @param array $item Row data. */ public function column_cb( $item ): string { // F088 — plugin-managed rows carry no checkbox, so they cannot be // swept into a bulk delete. handle_bulk_actions() enforces the same // rule server-side. if ( ProtectedServers::is_protected_server( $item ) ) { return ''; } return sprintf( '', (int) $item['id'] ); } /** * Fallback column renderer for the merged `route` column * (`/`, with duplicate slashes at the join collapsed). * * @param array $item Row data. * @param string $column_name Column key being rendered. */ public function column_default( $item, $column_name ): string { switch ( $column_name ) { case 'route': $namespace = trim( (string) $item['server_route_namespace'], '/' ); $route = ltrim( (string) $item['server_route'], '/' ); $combined = '' === $namespace ? $route : $namespace . '/' . $route; return esc_html( $combined ); default: return ''; } } /** * Name column with row actions (Edit + conditional Delete) and the * Recommended badge. * * Source-repo behavior preserved: Delete row action only appears for * 'database'-source rows (the seeded default-plugin row is not deletable * from the UI). F088 additionally excludes every plugin-managed row — * the AcrossAI row is 'database'-source but seeder-owned. * * @param array $item Row data. */ public function column_name( $item ): string { $edit_url = add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'edit', 'server' => (int) $item['id'], ), admin_url( 'admin.php' ) ); $row_actions = array( 'edit' => sprintf( '%s', esc_url( $edit_url ), esc_html__( 'Edit', 'acrossai-mcp-manager' ) ), ); if ( 'database' === $item['registered_from'] && ! ProtectedServers::is_protected_server( $item ) ) { $delete_url = wp_nonce_url( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'delete', 'server' => (int) $item['id'], ), admin_url( 'admin.php' ) ), 'acrossai_mcp_delete_' . (int) $item['id'] ); $row_actions['delete'] = sprintf( '%s', esc_url( $delete_url ), esc_js( __( 'Are you sure you want to delete this server? This cannot be undone.', 'acrossai-mcp-manager' ) ), esc_html__( 'Delete', 'acrossai-mcp-manager' ) ); } $badge = ProtectedServers::is_recommended( (string) $item['slug'] ) ? ' ' . ProtectedServers::recommended_badge() : ''; return sprintf( '%s%s%s', esc_url( $edit_url ), esc_html( $item['name'] ), $badge, $this->row_actions( $row_actions ) ); } /** * Source badge: plugin / database / theme / core. * * @param array $item Row data. */ public function column_source( $item ): string { $source = $item['registered_from']; $labels = array( 'plugin' => __( 'Plugin', 'acrossai-mcp-manager' ), 'database' => __( 'Database', 'acrossai-mcp-manager' ), 'theme' => __( 'Theme', 'acrossai-mcp-manager' ), 'core' => __( 'Core', 'acrossai-mcp-manager' ), ); $label = $labels[ $source ] ?? $source; $class = 'acrossai-source-badge acrossai-source-' . sanitize_html_class( $source ); return sprintf( '%s', esc_attr( $class ), esc_html( $label ) ); } /** * Status badge (Active / Inactive). * * @param array $item Row data. */ public function column_status( $item ): string { if ( $item['enabled'] ) { return '' . esc_html__( 'Active', 'acrossai-mcp-manager' ) . ''; } return '' . esc_html__( 'Inactive', 'acrossai-mcp-manager' ) . ''; } /** * Actions column: Edit + Enable/Disable toggle + quick-access links that * jump directly to the corresponding tab on the server-edit page. The * toggle carries a per-row nonce; Edit and quick-links are plain nav. * * @param array $item Row data. */ public function column_actions( $item ): string { $edit_url = add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'edit', 'server' => (int) $item['id'], ), admin_url( 'admin.php' ) ); $edit_html = sprintf( '%s', esc_url( $edit_url ), esc_html__( 'Edit', 'acrossai-mcp-manager' ) ); $toggle_url = wp_nonce_url( add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'toggle_status', 'server' => (int) $item['id'], ), admin_url( 'admin.php' ) ), 'acrossai_mcp_toggle_' . (int) $item['id'] ); if ( $item['enabled'] ) { $toggle_html = sprintf( '%s', esc_url( $toggle_url ), esc_html__( 'Disable', 'acrossai-mcp-manager' ) ); } else { // F090 (T033) — a server whose type has an unmet requirement cannot be // enabled. Render the affordance disabled WITH its reason rather than // letting the operator click into a refusal. // // This is presentation only. The boundary is server-side in // ServerEnablement::set(), which every write path routes through — a // disabled link stops the honest click, not a forged request. // Only the HARD case disables the affordance. A known type whose // plugin is missing is now enablable — the operator's intent is // recorded, the server says what it still needs, and installing the // plugin makes it live with no further click. Disabling Enable here // would strand exactly the operator who has not installed the // add-on yet, including inside Quick Connect. $type_error = ServerTypes::enablement_error( (string) ( $item['server_type'] ?? '' ) ); if ( null !== $type_error ) { $toggle_html = sprintf( '%s', esc_attr( $type_error->get_error_message() ), esc_html__( 'Enable', 'acrossai-mcp-manager' ) ); } else { $toggle_html = sprintf( '%s', esc_url( $toggle_url ), esc_html__( 'Enable', 'acrossai-mcp-manager' ) ); } } // F084 — 'Connectors' and 'MCP Clients' are no longer top-level tabs; // they are level-2 methods inside the Connect tab. `is_method` selects // the level-2 URL builder below. The other three shortcuts are // unchanged: same five pills, same labels, same icons, same order. $quick_links = array( 'ai-connectors' => array( 'label' => __( 'Connectors', 'acrossai-mcp-manager' ), 'icon' => 'admin-plugins', 'is_method' => true, ), 'access-control' => array( 'label' => __( 'Access Control', 'acrossai-mcp-manager' ), 'icon' => 'shield', ), 'abilities' => array( 'label' => __( 'Abilities', 'acrossai-mcp-manager' ), 'icon' => 'superhero-alt', ), 'clients' => array( 'label' => __( 'MCP Clients', 'acrossai-mcp-manager' ), 'icon' => 'admin-users', 'is_method' => true, ), ); // A Fleet server is reached by paired WordPress sites, never by an AI // client, so it has no Connect tab. Both `is_method` shortcuts point // INTO that tab — left in place they would be two pills linking to a // screen that does not render for this row. Access Control and // Abilities stay: those are real tabs here, and they are what decides // what a paired hub may do. $is_fleet = ServerTypes::FLEET === (string) ( $item['server_type'] ?? '' ); if ( $is_fleet ) { $quick_links = array_filter( $quick_links, static function ( array $meta ): bool { return empty( $meta['is_method'] ); } ); } $links_html = ''; foreach ( $quick_links as $tab_slug => $meta ) { // F084 — `method_url()` returns a RAW url by contract; it is // esc_url()'d at the output site immediately below, same as the // top-level branch. See the output-site inventory in // specs/084-connect-tab-merge/contracts/connect-method-registration.md. $tab_url = empty( $meta['is_method'] ) ? add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'action' => 'edit', 'server' => (int) $item['id'], 'tab' => $tab_slug, ), admin_url( 'admin.php' ) ) : ConnectTab::method_url( $item, $tab_slug ); $links_html .= sprintf( '%s', esc_url( $tab_url ), esc_attr( $meta['icon'] ), esc_html( $meta['label'] ) ); } // F072 FR-005 — per-row Quick Connect via AcrossAI pill, emitted outside the loop // because Quick Connect via AcrossAI is not a tab (no ?tab= param). Server id is // deep-linked so Step 1 opens with this row preselected. // // Skipped for a Fleet server on the same grounds as the two pills // above: the wizard walks an operator through pointing an AI client at // a server, and this is the one kind of server no AI client connects // to. Offering it here would send them down a flow whose last step // cannot succeed. if ( ! $is_fleet ) { $quick_connect_url = add_query_arg( array( 'page' => AdminPageSlugs::PARENT, 'quick-connect' => '1', 'step' => '1', 'server' => (int) $item['id'], ), admin_url( 'admin.php' ) ); $links_html .= sprintf( '%s', esc_url( $quick_connect_url ), esc_html__( 'Connect via AcrossAI', 'acrossai-mcp-manager' ) ); } return sprintf( '
%s%s
', $edit_html, $toggle_html, $links_html ); } }