plugin_name = ACROSSAI_MCP_MANAGER_PLUGIN_NAME_SLUG; $this->version = ACROSSAI_MCP_MANAGER_VERSION; // Asset manifest reads are deferred to enqueue_*() — see notes there // for the file_exists() guard (FR-019) and the screen-ID guard (FR-017). } /** * Whether the current admin screen is one of the plugin's own pages (FR-017). * * @return bool */ private function is_plugin_admin_screen(): bool { if ( ! function_exists( 'get_current_screen' ) ) { return false; } $screen = get_current_screen(); if ( ! $screen ) { return false; } return in_array( $screen->id, AdminPageSlugs::plugin_screen_ids(), true ); } /** * Lazy-load an asset manifest. Returns null when the file is missing * so callers can silently skip enqueue (FR-019). * * @param string $relative_path Manifest path relative to the plugin root. * @return array{dependencies: string[], version: string}|null */ private function read_asset_manifest( string $relative_path ): ?array { $path = \ACROSSAI_MCP_MANAGER_PLUGIN_PATH . $relative_path; if ( ! file_exists( $path ) ) { return null; } $asset = include $path; if ( ! is_array( $asset ) || ! isset( $asset['version'], $asset['dependencies'] ) ) { return null; } return $asset; } /** * Enqueue backend.css on plugin admin pages only. * * Wired on `admin_enqueue_scripts` by Includes\Main::define_admin_hooks(). */ public function enqueue_styles(): void { if ( ! $this->is_plugin_admin_screen() ) { return; } $asset = $this->read_asset_manifest( 'build/css/backend.asset.php' ); if ( null === $asset ) { return; } wp_enqueue_style( $this->plugin_name, esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/css/backend.css' ), $asset['dependencies'], $asset['version'], 'all' ); } /** * Enqueue backend.js on plugin admin pages only. * * Wired on `admin_enqueue_scripts` by Includes\Main::define_admin_hooks(). */ public function enqueue_scripts(): void { if ( ! $this->is_plugin_admin_screen() ) { return; } $asset = $this->read_asset_manifest( 'build/js/backend.asset.php' ); if ( null === $asset ) { return; } wp_enqueue_script( $this->plugin_name, esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/js/backend.js' ), $asset['dependencies'], $asset['version'], true // load in footer ); // F015 — Access Control tab React app (vendor's component). // Only enqueue on the per-server-edit page with tab=access-control so we // don't ship the React bundle on unrelated screens. $this->maybe_enqueue_access_control_app(); // F017 — Abilities tab React app (@wordpress/dataviews). // Scoped to the Abilities tab only — same guard shape as F015. $this->maybe_enqueue_abilities_app(); // F020 — Tools tab React app (hand-rolled shuttle picker). // Scoped to the Tools tab only — same guard shape as F015/F017. $this->maybe_enqueue_tools_app(); // The Embeds tab and its bundle moved to acrossai-pro, which registers // them itself through AbstractReactMountServerTab. Nothing to enqueue // here; see that class for the pattern a companion plugin follows. // F069 Quick Connect via AcrossAI wizard — enqueue only on ?quick-connect=1. // SC-007: the ~200KB React bundle MUST NOT load on the list-table // view or any per-server-edit tab. $this->maybe_enqueue_quick_connect_app(); // F095 — AI Connectors tab bundle. $this->maybe_enqueue_ai_connectors_app(); } /** * F069 T018 — Enqueue the Quick Connect via AcrossAI wizard React app. * * Gated on `?quick-connect=1` — bundle never loads on any other admin * page (SC-007). Mirrors the F017 / F020 enqueue shape (asset.php * manifest → wp_enqueue_script → wp_localize_script for bootstrap * payload → optional CSS enqueue via file_exists guard). * * @since 0.2.11 * @return void */ private function maybe_enqueue_quick_connect_app(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only routing check. if ( empty( $_GET['quick-connect'] ) || '1' !== (string) $_GET['quick-connect'] ) { return; } // phpcs:enable WordPress.Security.NonceVerification.Recommended $asset = $this->read_asset_manifest( 'build/js/quick-connect.asset.php' ); if ( null === $asset ) { return; } wp_enqueue_script( 'acrossai-mcp-manager-quick-connect', esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/js/quick-connect.js' ), $asset['dependencies'], $asset['version'], true // load in footer. ); wp_set_script_translations( 'acrossai-mcp-manager-quick-connect', 'acrossai-mcp-manager' ); $css_path = \ACROSSAI_MCP_MANAGER_PLUGIN_PATH . 'build/js/quick-connect.css'; if ( file_exists( $css_path ) ) { wp_enqueue_style( 'acrossai-mcp-manager-quick-connect', esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/js/quick-connect.css' ), array(), $asset['version'] ); } wp_localize_script( 'acrossai-mcp-manager-quick-connect', 'acrossaiMcpQuickConnect', array( 'restUrl' => esc_url_raw( rest_url( 'acrossai-mcp-manager/v1/quick-connect' ) ), 'restNonce' => wp_create_nonce( 'wp_rest' ), 'adminUrl' => esc_url_raw( admin_url( 'admin.php?page=acrossai_mcp_manager' ) ), 'siteUrl' => esc_url_raw( untrailingslashit( home_url() ) ), 'logoUrl' => esc_url_raw( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'assets/quick-connect/acrossai-logo.svg' ), // F069 — square brand icon shown on the initial-hydrate // loading screen. Kept at assets/quick-connect/icon.svg (a // direct copy of .wordpress-org/icon.svg — that dotfile // directory is routinely blocked at the host / Apache level, // so pointing the browser there 404s on real installs). // When updating the icon, replace BOTH files so the WP.org // plugin listing and the wizard stay in sync. 'iconUrl' => esc_url_raw( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'assets/quick-connect/icon.svg' ), // Access Control wiring — MUST mirror the values passed to // the per-server-edit tab bootstrap (see the AC-tab enqueue // block above) so the wizard's Step 2 uses the same slug + // REST root the server tab does. 'acPluginSlug' => \AcrossAI_MCP_Manager\Includes\AccessControl\AcrossAI_MCP_Access_Control::TABLE_SLUG, 'acNamespace' => 'acrossai-mcp-manager', 'restApiRoot' => esc_url_raw( untrailingslashit( rest_url() ) ), // F075 — local-dev TLS bypass affordance. When `enabled` is true, // Step 11 renders a warning callout above the client config code // block. The JSON string itself already contains // NODE_TLS_REJECT_UNAUTHORIZED because ConnectionMethodRegistry // calls get_config_snippet() which routes through // AbstractMCPClient::build_env() (same source-of-truth as the // per-server tab notice — copy MUST match MCPClientsBlock). 'tlsBypass' => array( 'enabled' => LocalEnvironment::needs_tls_bypass(), 'message' => __( 'Local dev — added NODE_TLS_REJECT_UNAUTHORIZED: "0" for local testing (never use on a live site).', 'acrossai-mcp-manager' ), 'linkText' => __( 'More info', 'acrossai-mcp-manager' ), 'docUrl' => LocalEnvironment::troubleshooting_doc_url(), ), ) ); } /** * F069 — Full-page mode body class. * * Appends `acrossai-mcp-quick-connect-fullpage` to `` when the wizard * URL is active (`?quick-connect=1`). The CSS scoped under that class hides * the WP admin sidebar + admin bar + footer so the wizard fills the entire * viewport (WooCommerce setup-wizard pattern). * * @since 0.2.11 * @param string $classes Space-separated body class string. * @return string Amended class string. */ public function full_page_body_class( $classes ): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing check. if ( empty( $_GET['quick-connect'] ) || '1' !== (string) $_GET['quick-connect'] ) { return (string) $classes; } return trim( $classes . ' acrossai-mcp-quick-connect-fullpage' ); } /** * F069 — Suppress core admin notices while the wizard is active. * * Notices from other plugins (update prompts, promo banners) break the * wizard's focused-attention layout. Fires on `in_admin_header` — after * WordPress has set up its notice queue but before it's rendered. * * @since 0.2.11 * @return void */ public function suppress_admin_notices_on_quick_connect(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing check. if ( empty( $_GET['quick-connect'] ) || '1' !== (string) $_GET['quick-connect'] ) { return; } remove_all_actions( 'admin_notices' ); remove_all_actions( 'all_admin_notices' ); remove_all_actions( 'user_admin_notices' ); remove_all_actions( 'network_admin_notices' ); } /** * Enqueue the vendor AccessControl React app on the Access Control tab. * * @since 0.0.7 * @return void */ private function maybe_enqueue_access_control_app(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only routing check. $is_edit = isset( $_GET['action'] ) && 'edit' === sanitize_key( wp_unslash( $_GET['action'] ) ); $is_ac = isset( $_GET['tab'] ) && 'access-control' === sanitize_key( wp_unslash( $_GET['tab'] ) ); if ( ! $is_edit || ! $is_ac ) { return; } // phpcs:enable $asset = $this->read_asset_manifest( 'build/js/access-control.asset.php' ); if ( null === $asset ) { return; } $handle = $this->plugin_name . '-access-control'; wp_enqueue_script( $handle, esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/js/access-control.js' ), $asset['dependencies'], $asset['version'], true ); // Vendor CSS bundled alongside the JS entry (webpack emits alongside // the .js/.asset.php). Same handle so the style is deregistered when // the script is. wp_enqueue_style( $handle, esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/js/access-control.css' ), array(), $asset['version'] ); // phpcs:disable WordPress.Security.NonceVerification.Recommended $server_id = isset( $_GET['server'] ) ? absint( wp_unslash( $_GET['server'] ) ) : 0; // phpcs:enable $server_slug = ''; if ( $server_id > 0 ) { $rows = \AcrossAI_MCP_Manager\Includes\Database\MCPServer\Query::instance()->query( array( 'id' => $server_id, 'number' => 1, ) ); if ( ! empty( $rows ) ) { $server_slug = (string) $rows[0]->server_slug; } } wp_localize_script( $handle, 'acrossaiMcpAccessControl', array( 'pluginSlug' => \AcrossAI_MCP_Manager\Includes\AccessControl\AcrossAI_MCP_Access_Control::TABLE_SLUG, 'namespace' => 'acrossai-mcp-manager', 'resourceKey' => $server_slug, // The vendor's React component concatenates restApiRoot + '/wpb-ac/…'. // `rest_url()` returns with a trailing slash, which would produce // `/wp-json//wpb-ac/…` → 404. Strip the trailing slash here. 'restApiRoot' => esc_url_raw( untrailingslashit( rest_url() ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), ) ); } /** * Enqueue the F017 Abilities tab React app on the Abilities tab only. * * Mirrors the F015 `maybe_enqueue_access_control_app()` shape verbatim — * `?action=edit` + `?tab=abilities` guard, silent bail on missing asset * manifest (FR-019), localize the `acrossaiMcpAbilities` config. * * @since 0.1.0 * @return void */ private function maybe_enqueue_abilities_app(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only routing check. $is_edit = isset( $_GET['action'] ) && 'edit' === sanitize_key( wp_unslash( $_GET['action'] ) ); $is_abilities = isset( $_GET['tab'] ) && 'abilities' === sanitize_key( wp_unslash( $_GET['tab'] ) ); if ( ! $is_edit || ! $is_abilities ) { return; } // phpcs:enable $asset = $this->read_asset_manifest( 'build/js/abilities.asset.php' ); if ( null === $asset ) { return; } $handle = $this->plugin_name . '-abilities'; wp_enqueue_script( $handle, esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/js/abilities.js' ), $asset['dependencies'], $asset['version'], true ); // SCSS is optional — emit a matching stylesheet only if webpack // produced `build/js/abilities.css` alongside the JS. $css_path = \ACROSSAI_MCP_MANAGER_PLUGIN_PATH . 'build/js/abilities.css'; if ( file_exists( $css_path ) ) { wp_enqueue_style( $handle, esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/js/abilities.css' ), array(), $asset['version'] ); } // phpcs:disable WordPress.Security.NonceVerification.Recommended $server_id = isset( $_GET['server'] ) ? absint( wp_unslash( $_GET['server'] ) ) : 0; // phpcs:enable $server_slug = ''; if ( $server_id > 0 ) { $rows = \AcrossAI_MCP_Manager\Includes\Database\MCPServer\Query::instance()->query( array( 'id' => $server_id, 'number' => 1, ) ); if ( ! empty( $rows ) ) { $server_slug = (string) $rows[0]->server_slug; } } wp_localize_script( $handle, 'acrossaiMcpAbilities', array( 'serverId' => $server_id, 'serverSlug' => $server_slug, // B17 defense — `rest_url()` returns with a trailing slash; // the client concatenates `restApiRoot + '/acrossai-mcp-manager/v1/…'` // so we strip the slash here to avoid `//`-doubled routes → 404. 'restApiRoot' => esc_url_raw( untrailingslashit( rest_url() ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'namespace' => 'acrossai-mcp-manager/v1', // Brand icon for the full-screen loading overlay — same asset // the Quick Connect wizard's hydrate/busy overlay uses. 'iconUrl' => esc_url_raw( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'assets/quick-connect/icon.svg' ), // F087 — the tool-level abilities this table DROPS: protocol // plumbing plus whatever companion plugins declare via // `acrossai_mcp_manager_tool_abilities`. PHP is the source of // truth; the JS literals are a boot fallback. 'toolAbilities' => ToolAbilities::get_slugs(), ) ); } /** * Enqueue the F020 Tools tab React shuttle picker on the Tools tab. * * Mirrors `maybe_enqueue_abilities_app()` verbatim — `?action=edit` + * `?tab=tools` guard, silent bail on missing asset manifest (FR-019), * localize `window.acrossaiMcpTools`. * * @since 0.1.0 * @return void */ private function maybe_enqueue_tools_app(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only routing check. $is_edit = isset( $_GET['action'] ) && 'edit' === sanitize_key( wp_unslash( $_GET['action'] ) ); $is_tools = isset( $_GET['tab'] ) && 'tools' === sanitize_key( wp_unslash( $_GET['tab'] ) ); if ( ! $is_edit || ! $is_tools ) { return; } // phpcs:enable $asset = $this->read_asset_manifest( 'build/js/tools.asset.php' ); if ( null === $asset ) { return; } $handle = $this->plugin_name . '-tools'; wp_enqueue_script( $handle, esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/js/tools.js' ), $asset['dependencies'], $asset['version'], true ); // Optional CSS — auto-extracted if src/scss/tools.scss is imported. $css_path = \ACROSSAI_MCP_MANAGER_PLUGIN_PATH . 'build/js/tools.css'; if ( file_exists( $css_path ) ) { wp_enqueue_style( $handle, esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/js/tools.css' ), array(), $asset['version'] ); } // phpcs:disable WordPress.Security.NonceVerification.Recommended $server_id = isset( $_GET['server'] ) ? absint( wp_unslash( $_GET['server'] ) ) : 0; // phpcs:enable $server_slug = ''; if ( $server_id > 0 ) { $rows = \AcrossAI_MCP_Manager\Includes\Database\MCPServer\Query::instance()->query( array( 'id' => $server_id, 'number' => 1, ) ); if ( ! empty( $rows ) ) { $server_slug = (string) $rows[0]->server_slug; } } wp_localize_script( $handle, 'acrossaiMcpTools', array( 'serverId' => $server_id, 'serverSlug' => $server_slug, 'restApiRoot' => esc_url_raw( untrailingslashit( rest_url() ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'namespace' => 'acrossai-mcp-manager/v1', // F087 — same list the Abilities tab uses, opposite job: the left // "All abilities" pool shows ONLY these. The "Added as tools" pane // is unfiltered so earlier picks round-trip on save. 'toolAbilities' => ToolAbilities::get_slugs(), ) ); } /** * F095 — AI Connectors tab bundle. * * Written to this plugin's enqueue shape rather than ported verbatim. The * companion's version carried two things that no longer apply: a * `class_exists( AuthorizationController )` probe to stand down while * mcp-manager owned the path — we ARE that class now — and * `HostCapabilities` helpers that asked the host which method was being * requested. We are the host, so the routing check is read directly. * * Covers both addresses: `?tab=connect&method=ai-connectors` and the bare * `?tab=connect` default view, where MethodRegistry picks the first method * by priority — which is this one (10). Gating on a named method alone * would leave the default view rendering unstyled. * * @return void */ private function maybe_enqueue_ai_connectors_app(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only routing check. $is_edit = isset( $_GET['action'] ) && 'edit' === sanitize_key( wp_unslash( $_GET['action'] ) ); $is_connect = isset( $_GET['tab'] ) && 'connect' === sanitize_key( wp_unslash( $_GET['tab'] ) ); $method = isset( $_GET['method'] ) ? sanitize_key( wp_unslash( $_GET['method'] ) ) : ''; // phpcs:enable $wants_this_panel = '' === $method || 'ai-connectors' === $method; if ( ! $is_edit || ! $is_connect || ! $wants_this_panel || ! current_user_can( 'manage_options' ) ) { return; } $asset = $this->read_asset_manifest( 'build/js/ai-connectors.asset.php' ); if ( null === $asset ) { return; } $handle = $this->plugin_name . '-ai-connectors'; wp_enqueue_script( $handle, esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/js/ai-connectors.js' ), $asset['dependencies'], $asset['version'], true ); $css_path = \ACROSSAI_MCP_MANAGER_PLUGIN_PATH . 'build/js/ai-connectors.css'; if ( file_exists( $css_path ) ) { wp_enqueue_style( $handle, esc_url( \ACROSSAI_MCP_MANAGER_PLUGIN_URL . 'build/js/ai-connectors.css' ), array(), $asset['version'] ); } // REST namespace stays `acrossai-mcp-manager/v1` — in-field DCR clients // cached this URL, so it is a contract, not a naming preference. wp_localize_script( $handle, 'acrossaiMcpConnectors', array( 'restEndpoint' => esc_url_raw( rest_url( 'acrossai-mcp-manager/v1/oauth/generate-client' ) ), 'namespace' => 'acrossai-mcp-manager/v1', 'copied' => __( 'Copied!', 'acrossai-mcp-manager' ), 'reveal' => __( 'Reveal', 'acrossai-mcp-manager' ), 'hide' => __( 'Hide', 'acrossai-mcp-manager' ), 'working' => __( 'Generating credentials…', 'acrossai-mcp-manager' ), 'failed' => __( 'Failed to generate credentials. Please try again.', 'acrossai-mcp-manager' ), 'missingCtx' => __( 'Missing server context. Reload the page and try again.', 'acrossai-mcp-manager' ), 'confirmRegenerate' => __( 'Regenerating will revoke every outstanding token for this connector. Continue?', 'acrossai-mcp-manager' ), 'issued' => __( 'Credentials generated', 'acrossai-mcp-manager' ), 'clientId' => __( 'OAuth Client ID', 'acrossai-mcp-manager' ), 'secret' => __( 'OAuth Client Secret (visible once — copy it now)', 'acrossai-mcp-manager' ), 'setup' => __( 'Setup instructions', 'acrossai-mcp-manager' ), 'copy' => __( 'Copy', 'acrossai-mcp-manager' ), ) ); } }