*/ final class Main { /** * The single instance of the class. * * @var AcrossAI_MCP_Manager * @since 0.0.1 */ protected static $_instance = null; /** * The autoloader instance. * * @since 0.0.1 * @access protected * @var Autoloader $autoloader The plugin autoloader instance. */ protected $autoloader; /** * The loader that's responsible for maintaining and registering all hooks that power * the plugin. * * @since 0.0.1 * @access protected * @var Loader $loader Maintains and registers all hooks for the plugin. */ protected $loader; /** * The unique identifier of this plugin. * * @since 0.0.1 * @access protected * @var string $plugin_name The string used to uniquely identify this plugin. */ protected $plugin_name; /** * The plugin dir path * * @since 0.0.1 * @access protected * @var string $plugin_path The string for plugin dir path */ protected $plugin_path; /** * The current version of the plugin. * * @since 0.0.1 * @access protected * @var string $version The current version of the plugin. */ protected $version; /** * Plugin directory path. * * @var string */ protected $plugin_dir; /** * Define the core functionality of the plugin. * * Set the plugin name and the plugin version that can be used throughout the plugin. * Load the dependencies, define the locale, and set the hooks for the admin area and * the public-facing side of the site. * * @since 0.0.1 */ public function __construct() { $this->define_constants(); $this->version = ACROSSAI_MCP_MANAGER_VERSION; $this->plugin_name = 'acrossai-mcp-manager'; $this->plugin_dir = ACROSSAI_MCP_MANAGER_PLUGIN_PATH; $this->load_composer_dependencies(); $this->load_dependencies(); $this->register_activity_log_table(); $this->set_locale(); $this->load_hooks(); } /** * Teach `$wpdb` the activity log's table name, on EVERY request. * * BerlinDB does not resolve a table name from the Query's `$table_name` * directly. `Query::get_table_name()` calls `get_table_prefix()`, which * reads `$wpdb->{$prefixed_name} ?? ''` — and that property is only ever * set by the Table object's own constructor. Construct no Table, and the * name resolves to the EMPTY STRING: the insert becomes * `SHOW FULL COLUMNS FROM ``` and fails, `add_item()` returns false, and * the Recorder's A15 try/catch swallows it. Nothing is written and nothing * is reported. * * Reconciliation runs on `admin_init` (see reconcile_database_schemas()), * which is enough to CREATE and migrate the table but not to make it * writable anywhere else — `admin_init` does not fire on a front-end REST * request or on WP-Cron. Both of those are load-bearing here: every MCP * call is a REST request, and the retention sweep is a cron event. The log * recorded nothing from real traffic, while every test passed, because the * test bootstrap constructs the Table itself. * * Safe to do unconditionally and this early. Table construction performs no * queries — `init()` sanitizes the name and calls `set_db_interface()`, * which only assigns `$wpdb` properties. The schema work lives in * `maybe_upgrade()`, which is NOT called here. * * Deliberately not gated on the logging toggles. The table must be * addressable for reads, retention and purge regardless of whether * recording is currently on, and an unresolvable name fails silently in * every one of those paths too. */ private function register_activity_log_table(): void { \AcrossAI_MCP_Manager\Includes\Database\ActivityLog\Table::instance(); } /** * Main AcrossAI_MCP_Manager Instance. * * Ensures only one instance of WooCommerce is loaded or can be loaded. * * @since 0.0.1 * @static * @see AcrossAI_MCP_Manager() * @return AcrossAI_MCP_Manager - Main instance. */ public static function instance() { if ( null === self::$_instance ) { self::$_instance = new self(); } return self::$_instance; } /** * Define WCE Constants */ private function define_constants() { $this->define( 'ACROSSAI_MCP_MANAGER_PLUGIN_BASENAME', plugin_basename( \ACROSSAI_MCP_MANAGER_PLUGIN_FILE ) ); $this->define( 'ACROSSAI_MCP_MANAGER_PLUGIN_PATH', plugin_dir_path( \ACROSSAI_MCP_MANAGER_PLUGIN_FILE ) ); $this->define( 'ACROSSAI_MCP_MANAGER_PLUGIN_URL', plugin_dir_url( \ACROSSAI_MCP_MANAGER_PLUGIN_FILE ) ); $this->define( 'ACROSSAI_MCP_MANAGER_PLUGIN_NAME_SLUG', 'acrossai-mcp-manager' ); $this->define( 'ACROSSAI_MCP_MANAGER_PLUGIN_NAME', 'AcrossAI MCP Manager' ); $this->define( 'ACROSSAI_MCP_MANAGER_VERSION', '0.4.6' ); } /** * Define constant if not already set * * @param string $name * @param string|bool $value */ private function define( $name, $value ) { if ( ! defined( $name ) ) { define( $name, $value ); } } /** * Register all the hook once all the active plugins are loaded * * Uses the plugins_loaded to load all the hooks and filters * * @since 0.0.1 * @access private */ public function load_hooks() { /** * Check if plugin can be loaded safely or not * * @since 0.0.1 */ if ( apply_filters( 'acrossai_mcp_manager_load', true ) ) { $this->bootstrap_database_tables(); $this->define_admin_hooks(); $this->define_public_hooks(); } } /** * Instantiate the four BerlinDB Table subclasses at request time. * * BerlinDB v3 requires the Table subclass to be instantiated so its * `sunrise()` / `set_prefixes()` boot registers the physical table * name with the DB interface. Without this, Query subclasses fall * back to using $table_alias as the FROM clause, producing * `Table 'db.' doesn't exist` errors at runtime. * * Matches the sibling plugin's boot pattern * (acrossai-abilities-manager Main::define_admin_hooks:349) but is * hoisted here so it also covers public/REST request paths * (MCP\Controller::has_any_enabled_server hits Query on rest_api_init). * * @since 0.0.1 * @return void */ private function bootstrap_database_tables() { \AcrossAI_MCP_Manager\Includes\Database\MCPServer\Table::instance(); \AcrossAI_MCP_Manager\Includes\Database\CliAuthLog\Table::instance(); // Feature 017 — per DEC-BERLINDB-TABLE-REQUEST-BOOT. \AcrossAI_MCP_Manager\Includes\Database\MCPServerAbility\Table::instance(); // Feature 020 — per DEC-BERLINDB-TABLE-REQUEST-BOOT. Co-commit invariant // with Activator's MCPServerToolTable::instance()->maybe_upgrade() — // omitting either produces the "Table doesn't exist" fallback bug or the // silent-fail-open enforcement gate (SEC-020-T-001). \AcrossAI_MCP_Manager\Includes\Database\MCPServerTool\Table::instance(); // F037 — MCPServerMeta per-request boot per DEC-BERLINDB-TABLE-REQUEST-BOOT. // Generic per-server key-value meta table (WP-canonical shape: // meta_id, server_id, meta_key, meta_value), reusable for any // per-server setting. Its one caller today is acrossai-pro's Embeds // tab (`_embeds_enabled` + `_embeds_clients`), which reaches it across // the plugin boundary — this plugin writes nothing here itself, and // must keep booting the table regardless, or Pro's settings have // nowhere to live. // // This table is also why a per-server setting needs no schema change: // it replaced the `wp_acrossai_mcp_server_embed_transports` junction // table and the `wp_acrossai_mcp_servers.embeds_enabled` column, both // DROPped by MCPServer\Table::upgrade_to_1_1_4. \AcrossAI_MCP_Manager\Includes\Database\MCPServerMeta\Table::instance(); // F095 — OAuth tables, per DEC-BERLINDB-TABLE-REQUEST-BOOT. Request-time // instantiation is NOT optional: activation-time Table::instance() alone // leaves BerlinDB's DB interface empty on every subsequent request, after // which Query silently falls back to $table_alias as the FROM clause and // returns wrong results rather than erroring. // // Order is Tokens -> AuthCodes -> Clients per D31 (F032). \AcrossAI_MCP_Manager\Includes\Database\OAuthTokens\Table::instance(); \AcrossAI_MCP_Manager\Includes\Database\OAuthAuthCodes\Table::instance(); \AcrossAI_MCP_Manager\Includes\Database\OAuthClients\Table::instance(); \AcrossAI_MCP_Manager\Includes\Database\ConnectorApprovedUsers\Table::instance(); } /** * F029 — Run `maybe_upgrade()` on every BerlinDB Table on admin_init * so schema-version bumps (e.g. F029's CliAuthLog 1.0.1 + MCPServer * 1.1.1) roll out on the next admin page load, without operators * having to deactivate + reactivate the plugin. * * `Activator::activate()` already calls `maybe_upgrade()` on every * table at plugin-activation time — but activation only fires once. * For live installs that upgrade in place (composer / wp-cli plugin * update / manual file replace), no code path was reconciling schema * versions until this hook was added, so any table whose Schema.php * gained columns or bumped widths after the initial install silently * stayed drifted — including the pre-F029 F025 `tool_*` columns on * `acrossai_mcp_servers` and the `status`/`failure_code`/ * `app_password_uuid` widths on `acrossai_mcp_cli_auth_logs`. * * Cost per admin request: five cheap option reads for the version checks * (needs_upgrade() short-circuits when the stored version matches), plus one * non-autoloaded read each for the F091 fingerprint and the F083 done-flag. * Real DDL runs only on the FIRST admin request after a version bump or a * change to a declared column set. * * Runs at priority 3 so it fires BEFORE `Settings::maybe_seed_default_server` * (priority 4) and `Settings::handle_actions` (priority 5) — both of * which read from these tables and would otherwise race a drifted * schema on the first post-upgrade admin request. * * Wired by `define_admin_hooks()` per Constitution §A1. * * @return void */ public function reconcile_database_schemas(): void { $tables = array( \AcrossAI_MCP_Manager\Includes\Database\MCPServer\Table::instance(), \AcrossAI_MCP_Manager\Includes\Database\CliAuthLog\Table::instance(), \AcrossAI_MCP_Manager\Includes\Database\MCPServerAbility\Table::instance(), \AcrossAI_MCP_Manager\Includes\Database\MCPServerTool\Table::instance(), // F037 — reconcile MCPServerMeta schema on admin_init per D28. \AcrossAI_MCP_Manager\Includes\Database\MCPServerMeta\Table::instance(), // F095 — OAuth tables. Order Tokens -> AuthCodes -> Clients per D31. \AcrossAI_MCP_Manager\Includes\Database\OAuthTokens\Table::instance(), \AcrossAI_MCP_Manager\Includes\Database\OAuthAuthCodes\Table::instance(), \AcrossAI_MCP_Manager\Includes\Database\OAuthClients\Table::instance(), \AcrossAI_MCP_Manager\Includes\Database\ConnectorApprovedUsers\Table::instance(), // F096 — the activity log. Listed here as well as in Activator // because WordPress skips activation hooks on a plugin UPDATE // (B68): a site that already had this plugin active when 096 // shipped would otherwise have the toggles but no table, and every // write would fail silently. \AcrossAI_MCP_Manager\Includes\Database\ActivityLog\Table::instance(), ); $pending = 0; foreach ( $tables as $table ) { $table->maybe_upgrade(); if ( $table->needs_upgrade() ) { ++$pending; } } // FR-013b. A failure recorded here is usually transient: the version is // left unstamped precisely so the migration retries on the next admin // load. Once nothing is pending, the recorded failures describe a state // that no longer exists and must stop being reported, or Site Health // shows a permanent red mark for a problem that fixed itself. // // Cleared wholesale rather than per table because BerlinDB's `Table` // exposes no public name accessor, so a stored failure cannot be matched // to a Table object from out here. "Nothing needs upgrading" covers it. if ( 0 === $pending ) { \AcrossAI_MCP_Manager\Includes\Database\Support\SchemaFailureHealthCheck::instance()->clear_all(); } // F091 — the backstop for what `$upgrades` cannot reach: a column // declared without a paired callback, and a version stamped by // BerlinDB's phantom path with zero callbacks run. Deliberately AFTER // the loop above, so routine migrations run first and this only handles // what they could not. Add-only; see SchemaReconciler for why. $created = \AcrossAI_MCP_Manager\Includes\Database\SchemaReconciler::maybe_reconcile( $tables ); // F091 — values for the columns just created. MUST follow the reconciler // (it consumes the created-column report) and MUST precede // `Settings::maybe_seed_default_server` at priority 4, which reads the // server type this repairs. \AcrossAI_MCP_Manager\Includes\Database\MCPServer\CreatedColumnBackfill::apply( $created['acrossai_mcp_servers'] ?? array() ); // F095 — copy OAuth rows left behind by the acrossai-pro companion. // Deliberately LAST: the destination tables must exist and be current // before anything is copied into them. Gated on a done-option, so this is // a single option read on every site after the first successful run. \AcrossAI_MCP_Manager\Includes\Database\OAuthDataMigration::maybe_migrate(); // F095 — flush rewrites once per version. Activation flushes, but a // plugin UPDATE does not fire activation hooks, so without this the // five OAuth routes 404 after upgrade until someone re-saves // permalinks. Runs after `init` registered them; see the method. \AcrossAI_MCP_Manager\Includes\OAuth\OAuthRouter::maybe_flush_rewrites(); } /** * Load the required composer dependencies for this plugin. * * @since 0.0.1 * @access private */ private function load_composer_dependencies() { /** * Add composer file */ $plugin_path = ACROSSAI_MCP_MANAGER_PLUGIN_PATH; if ( file_exists( $plugin_path . 'vendor/autoload_packages.php' ) ) { require_once $plugin_path . 'vendor/autoload_packages.php'; } } /** * Load the required dependencies for this plugin. * * Include the following files that make up the plugin: * * - AcrossAI_MCP_Manager\Admin\Loader. Orchestrates the hooks of the plugin. * - AcrossAI_MCP_Manager\Admin\I18n. Defines internationalization functionality. * - AcrossAI_MCP_Manager\Admin\Main. Defines all hooks for the admin area. * - AcrossAI_MCP_Manager_Public. Defines all hooks for the public side of the site. * * Create an instance of the loader which will be used to register the hooks * with WordPress. * * @since 0.0.1 * @access private */ private function load_dependencies() { $this->loader = Loader::instance(); } /** * Define the locale for this plugin for internationalization. * * Uses the AcrossAI_MCP_Manager_i18n class in order to set the domain and to register the hook * with WordPress. * * @since 0.0.1 * @access private */ private function set_locale() { $i18n = new I18n(); // Now attach it to `init`, not `plugins_loaded` $this->loader->add_action( 'init', $i18n, 'do_load_textdomain' ); } /** * Register all of the hooks related to the admin area functionality * of the plugin. * * @since 0.0.1 * @access private */ private function define_admin_hooks() { $plugin_admin = \AcrossAI_MCP_Manager\Admin\Main::instance(); $this->loader->add_action( 'admin_enqueue_scripts', $plugin_admin, 'enqueue_styles' ); $this->loader->add_action( 'admin_enqueue_scripts', $plugin_admin, 'enqueue_scripts' ); // F069 — Full-page takeover when the wizard URL is active. Appends a // body class the SCSS keys off to hide WP admin chrome, and suppresses // core / plugin admin notices for the wizard render. $this->loader->add_filter( 'admin_body_class', $plugin_admin, 'full_page_body_class' ); $this->loader->add_action( 'in_admin_header', $plugin_admin, 'suppress_admin_notices_on_quick_connect', 1000 ); /** * Add the Plugin Submenus under the shared `acrossai` parent (Feature 010 — FR-021). * * Menu::register_submenu() registers MCP Manager (position 2), CLI Auth Log * (position 3), and Access Control (position 4, conditional). The shared * `acrossai` parent menu itself is auto-hooked internally by * \AcrossAI_Main_Menu\SettingsPage (bootstrapped from the plugin entry file * on plugins_loaded priority 0 per FR-029 / D15 / DEV4). * * plugin_action_links_ is the row-specific filter — fires * only for this plugin's row on the Plugins screen (FR-003). */ $menu = \AcrossAI_MCP_Manager\Admin\Partials\Menu::instance(); $this->loader->add_action( 'admin_menu', $menu, 'register_submenu' ); $this->loader->add_filter( 'plugin_action_links_' . ACROSSAI_MCP_MANAGER_PLUGIN_BASENAME, $menu, 'plugin_action_links', 10, 1 ); /** * Settings action handler hooks (US2 — Phase 2). * * `Settings` owns the list-page controller for MCP servers (toggle, * delete, create, bulk actions dispatched via * `?page=acrossai_mcp_manager&action=...`). It is deliberately kept * separate from `SettingsMenu` below, which owns the WordPress * Settings API surface on the shared `?page=acrossai-settings` page. * * - handle_actions runs at priority 5 on admin_init so it fires * BEFORE other admin_init handlers. * - render_action_result_notice consumes the `?notice=...` query var * set by handle_actions redirects (FR-016). */ // F029 — reconcile BerlinDB schema versions on admin_init so table // upgrades roll out on the next admin page load without operators // having to deactivate/reactivate. See Main::reconcile_database_schemas(). // Runs at priority 3 so it fires BEFORE Settings::maybe_seed_default_server (4) // and Settings::handle_actions (5) — both read from these tables. $this->loader->add_action( 'admin_init', $this, 'reconcile_database_schemas', 3 ); $settings = \AcrossAI_MCP_Manager\Admin\Partials\Settings::instance(); // Auto-heal the default MCP server row (reference-plugin pattern). // Runs at priority 4 so the row exists before handle_actions/list-render. $this->loader->add_action( 'admin_init', $settings, 'maybe_seed_default_server', 4 ); $this->loader->add_action( 'admin_init', $settings, 'handle_actions', 5 ); // One-shot: give every MCP Adapter server the `mcp-adapter/server-guide` // curated row it should have had. Priority 6 is load-bearing — it must // run AFTER maybe_seed_default_server (4) so a freshly seeded row is // included, and after handle_actions (5) so it never races a create or // delete in the same request. At 3, alongside the schema reconciler, it // would miss the seeded row and still record itself as done. $this->loader->add_action( 'admin_init', \AcrossAI_MCP_Manager\Includes\Database\MCPServer\ServerGuideBackfill::class, 'maybe_backfill', 6 ); // One-shot: restore a seeded server's declared tools when it has NONE. // Repairs installs activated before 0.3.6 fixed the activation order, on // which the seeder wrote its curated rows into a table that did not // exist yet and lost them silently. Same priority 6 and the same reasons // as the guide backfill above; it is a no-op on every healthy site, and // on a repaired one it never runs again. $this->loader->add_action( 'admin_init', \AcrossAI_MCP_Manager\Includes\Database\MCPServer\SeededToolsBackfill::class, 'maybe_backfill', 6 ); // F069 T015/T024 — Quick Connect via AcrossAI wizard activation redirect. Fires at // admin_init @ 5 (same slot as handle_actions but different handler); // on the very next admin page load after plugin activation, the // transient set inside acrossai_mcp_manager_activate() (T014) is // consumed here and the activating admin is redirected to step 1. $quick_connect_redirect = \AcrossAI_MCP_Manager\Admin\Partials\QuickConnect\ActivationRedirect::instance(); $this->loader->add_action( 'admin_init', $quick_connect_redirect, 'maybe_redirect', 5 ); // F069 T044/T045 — Quick Connect via AcrossAI admin-bar chip (US2). Persistent // re-launch affordance from any admin page. Priority 100 lands it // past most core / plugin nodes so it renders at the right side. $quick_connect_admin_bar = \AcrossAI_MCP_Manager\Admin\Partials\QuickConnect\AdminBarEntry::instance(); $this->loader->add_action( 'admin_bar_menu', $quick_connect_admin_bar, 'register_node', 100 ); /** * Shared AcrossAI Settings page — MCP tab (Feature 012). * * Registers the MCP tab on the vendor-owned `?page=acrossai-settings` * page via the `acrossai_settings_tabs` filter, and wires the tab's * three sections + toggles via the Settings API on admin_init. The * vendor's `SettingsPage` is guaranteed present at admin_init because * the acrossai-co/main-menu package is a hard-require in composer.json * (Feature 010 — D15 / DEV4). See DEC-VENDOR-SETTINGS-TAB-INTEGRATION. * * Loader-order note: this wiring lands after the Feature 011 * `bootstrap_database_tables()` call in load_hooks() (per * DEC-BERLINDB-TABLE-REQUEST-BOOT), so BerlinDB Tables are booted * before any admin_init handler runs. */ $settings_menu = \AcrossAI_MCP_Manager\Admin\Partials\SettingsMenu::instance(); $this->loader->add_filter( 'acrossai_settings_tabs', $settings_menu, 'register_tab' ); $this->loader->add_action( 'admin_init', $settings_menu, 'register_settings' ); /** * `acrossai_addons` filter — drop our own slug from the list rendered * on the shared Add-ons page (bundled in acrossai-co/main-menu 0.0.22+). * An already-active plugin should not appear as an installable add-on. */ $addons_filter = \AcrossAI_MCP_Manager\Admin\Partials\AddonsFilter::instance(); $this->loader->add_filter( 'acrossai_addons', $addons_filter, 'remove_self' ); /** * Admin notices — extracted to Admin\Partials\Notices per RT-2. * * Two hooks: * - render_action_result_notice ON admin_notices — one-shot flash message * consuming the `?notice=` query var set by Settings::handle_actions() * redirects (FR-016). Page-scoped; stays on the standard hook. * - register_shared_notices ON acrossai_notices — persistent-condition * records (missing MCP adapter, missing wpb-access-control library) * pushed into the cross-plugin collector introduced in * acrossai-co/main-menu 0.0.30. Vendor renders them on the Notices * submenu + WP-native dismissible summary; per-user fingerprint * dismissal is handled upstream. */ $notices = \AcrossAI_MCP_Manager\Admin\Partials\Notices::instance(); $this->loader->add_action( 'admin_notices', $notices, 'render_action_result_notice' ); $this->loader->add_filter( 'acrossai_notices', $notices, 'register_shared_notices' ); /** * ApplicationPasswords REST routes (US3 — Phase 2). * Registers POST /generate-app-password + GET /list-app-passwords * on the rest_api_init action. The Tokens tab JS calls these via * wp.apiFetch with the per-user `wp_rest` nonce. */ $application_passwords = \AcrossAI_MCP_Manager\Admin\Partials\ApplicationPasswords::instance(); $this->loader->add_action( 'rest_api_init', $application_passwords, 'register_rest_routes' ); // Feature 015 — Access Control v2 adoption. Replaces the previous Phase 7 // TODO block that referenced v1's ::instance() static (fatal in v2). All // wiring flows through the plugin-scoped wrapper; the mcp_adapter_pre_tool_call // filter (D18) is the canonical MCP-boundary enforcement hook. // NB: `register_default_providers` filter is intentionally NOT wired — // the vendor's AccessControlManager::load_providers() already registers // WpRoleProvider + WpUserProvider + WpCapabilityProvider as defaults. // Third-party plugins can still hook the // `acrossai_mcp_access_control_providers` filter to append their own. // (BuddyBoss + MemberPress providers moved to acrossai/user-access-pro // in wpb-access-control v3.0.0.) $access_control = \AcrossAI_MCP_Manager\Includes\AccessControl\AcrossAI_MCP_Access_Control::instance(); $this->loader->add_action( 'init', $access_control, 'boot_manager', 5 ); $this->loader->add_action( 'rest_api_init', $access_control, 'register_rest_api' ); // F015 library-missing warning: now surfaced via the shared // acrossai_notices filter (see Notices::register_shared_notices above). // ───────────────────────────────────────────────────────────────── // MCP per-server permission stack (F015 + F042). // // Filter 2 (F015, MCP dispatch layer): resolves server via // $server->get_server_id() → wpb-ac rule lookup → WP_Error 403 on deny // per operator rule; fail-open (allow) when no rule. // // F015 covers ALL THREE MCP pre-dispatch filters — tools, resources, // prompts — routed through the same shared enforcement helper. Prior // to 0.2.8 only pre_tool_call was hooked, which meant rule-configured // servers exposing resources or prompts were bypassed entirely by any // authenticated user (F1 fix — see review 2026-08-13). // // Filter 1 (F042, transport layer): resolves server via URL route → // MCPServerQuery lookup → returns 'manage_options' when no rule // (admin-only default), returns vendor default 'read' when any rule // exists (defers enforcement to F015 filter 2). Also returns // 'manage_options' when the wpb-access-control library is missing — // deliberate asymmetry with F015's fail-open so that a missing // dependency degrades to admin-only, not "wide open" (F2 fix — see // review 2026-08-13). // // Both are per-server (no hardcoded slugs, no default-server special // case). Together they form a defense-in-depth stack: rules-less // servers are hard-blocked to admins at filter 1; rule-configured // servers get precise enforcement at filter 2. // See specs/042-default-manage-options-access-rule/plan.md for the // full flow diagram and test-coverage map. // ───────────────────────────────────────────────────────────────── $this->loader->add_filter( 'mcp_adapter_pre_tool_call', $access_control, 'gate_mcp_tool_call', 10, 4 ); $this->loader->add_filter( 'mcp_adapter_pre_resource_read', $access_control, 'gate_mcp_resource_read', 10, 4 ); $this->loader->add_filter( 'mcp_adapter_pre_prompt_get', $access_control, 'gate_mcp_prompt_get', 10, 4 ); // The three pre-dispatch gates above stop a denied user EXECUTING, but // they never ran for the `*/list` primitives — so an excluded user // could still read the whole catalogue. This bites because configuring // any rule drops the transport gate from 'manage_options' to the // vendor default 'read' (see TransportPermissionDefault), and every // logged-in user holds 'read'. Measured against a server whose rule // was "Editor only": a subscriber completed `initialize` and received // 14 tools with names and descriptions, while `tools/call` correctly // denied. Same class of gap as the 0.2.8 F1 fix that added the // resource and prompt pre-dispatch gates — these are its discovery // siblings. Priority 10 / 3 args: the 0.7.0 list filters pass // ( $list, $server, $schema ). $this->loader->add_filter( 'mcp_adapter_tools_list', $access_control, 'gate_mcp_tools_list', 10, 3 ); $this->loader->add_filter( 'mcp_adapter_resources_list', $access_control, 'gate_mcp_resources_list', 10, 3 ); $this->loader->add_filter( 'mcp_adapter_prompts_list', $access_control, 'gate_mcp_prompts_list', 10, 3 ); $transport_default = \AcrossAI_MCP_Manager\Includes\AccessControl\TransportPermissionDefault::instance(); $this->loader->add_filter( 'mcp_adapter_default_transport_permission_user_capability', $transport_default, 'filter_default_capability', 10, 2 ); // F040 follow-up: the OAuth HTTPS and OAuth-cron-disabled notices moved // to the acrossai-ai-connectors companion plugin — mcp-manager no longer // owns the OAuth token endpoint or the OAuth cleanup cron, so those // warnings are companion territory now. The adapter-missing warning // (US4 — FR-015 + Q3) migrated to the shared `acrossai_notices` filter // registered above; dismiss + persistence are handled by // acrossai-co/main-menu 0.0.30's SummaryNoticeEmitter. // TODO (phase N): wire Admin\Partials\ApplicationPasswords. // $app_passwords = \AcrossAI_MCP_Manager\Admin\Partials\ApplicationPasswords::instance(); /** * Phase 4 gap closure — Feature-009 (2026-07-01). Boots the WP MCP * adapter singleton when at least one enabled MCP server row exists. * v0.0.4 wired this on `init` priority 1; the target design defers to * `rest_api_init` so `Plugin::instance()`'s internal REST route * registration lands during WordPress's REST bootstrap window. * Graceful when adapter package is absent (US3 — sets status * 'not-found'; `Notices::register_shared_notices()` surfaces the * warning via the acrossai_notices filter). */ $mcp_controller = \AcrossAI_MCP_Manager\Includes\MCP\Controller::instance(); $this->loader->add_action( 'rest_api_init', $mcp_controller, 'initialize_adapter' ); $this->loader->add_filter( 'mcp_adapter_default_server_config', $mcp_controller, 'filter_default_server_config' ); // Makes `is_enabled` govern the DEFAULT server too. That row is // `registered_from = 'plugin'`, so register_database_servers() never // sees it — the adapter's DefaultServerFactory creates it, and this // filter is its only off-switch. Unhooked, Disable was cosmetic: the // badge read "Inactive" while initialize returned HTTP 200 and // tools/list returned 4 tools. It also defeated DefaultServerSeeder, // which seeds `is_enabled => 0`, so fresh installs published an // endpoint the plugin had declared disabled. The Loader registers at // `plugins_loaded` priority 0, which matters here: the adapter // consults this filter from `wp_abilities_api_init` as well as from // its own init, and the callback has to be in place for both. See the // callback for why only the server — never the abilities — is // suppressed. $this->loader->add_filter( 'mcp_adapter_create_default_server', $mcp_controller, 'filter_create_default_server' ); /** * Feature 017 — Per-server Ability Selection. * * Two wiring points: * 1. REST controller for the per-server abilities read/write surface, * registered on `rest_api_init` (matching the F015 access-control * REST wiring shape immediately above). * 2. Call-time enforcement gate on `mcp_adapter_pre_tool_call` at * priority 20 — F015's `AcrossAI_MCP_Access_Control::gate_mcp_tool_call` * runs at priority 10, so F017's hidden-on-this-server decision * runs LATER and supersedes any F015 "allow" verdict. F017 never * overrides an F015 deny (see AbilityExposureGate::gate_tool_call_by_exposure). * Closes SEC-001 per FR-030. */ $abilities_rest = \AcrossAI_MCP_Manager\Includes\REST\AbilitiesController::instance(); $this->loader->add_action( 'rest_api_init', $abilities_rest, 'register_routes' ); $ability_exposure_gate = \AcrossAI_MCP_Manager\Includes\MCP\AbilityExposureGate::instance(); $this->loader->add_filter( 'mcp_adapter_pre_tool_call', $ability_exposure_gate, 'gate_tool_call_by_exposure', 20, 4 ); /** * Feature 020 — Per-server Tool Selection. * * Three wiring points: * 1. REST controller for the per-server tools read/write surface, * registered on `rest_api_init` (matches the F017 shape immediately above). * 2. Call-time enforcement gate on `mcp_adapter_pre_tool_call` at * priority 30 — F015 access control runs at 10, F017 ability * exposure at 20, F020 tool curation at 30. Deny-precedence honored; * F020 never re-allows an already-denied ability. Closes SEC-020-001. * 3. Cascade cleanup on BerlinDB's `mcp_server_deleted` action — fired * by `MCPServer\Query::delete_item()` and covers both single-row and * bulk-delete admin paths (FR-026). */ $tools_rest = \AcrossAI_MCP_Manager\Includes\REST\ToolsController::instance(); $this->loader->add_action( 'rest_api_init', $tools_rest, 'register_routes' ); /** * F069 T019/T024 — Quick Connect via AcrossAI wizard REST controller. * * Registers 3 routes under `/acrossai-mcp-manager/v1/quick-connect/*`: * GET /state — snapshot for the React app (servers, abilities, * plugin states, connection methods, scratchpad) * POST /step — persist per-step scratchpad + delegate authoritative * writes to MCPServerQuery + MCPServerAbilityQuery * POST /complete — clear scratchpad, 204 No Content * * All routes gated on manage_options (S2). POSTs additionally require * X-WP-Nonce for wp_rest (S1). See contracts/quick-connect-rest.md. */ $quick_connect_rest = \AcrossAI_MCP_Manager\Includes\REST\QuickConnectController::instance(); $this->loader->add_action( 'rest_api_init', $quick_connect_rest, 'register_routes' ); $tool_exposure_gate = \AcrossAI_MCP_Manager\Includes\MCP\ToolExposureGate::instance(); $this->loader->add_filter( 'mcp_adapter_pre_tool_call', $tool_exposure_gate, 'gate_tool_call_by_curation', 30, 4 ); /** * Feature — plugin-owned replacements for the three vendor default MCP * abilities (`mcp-adapter/discover-abilities`, `.../get-ability-info`, * `.../execute-ability`). * * Swaps their callbacks at registration time via `wp_register_ability_args` * (WP core hook) so the abilities keep vendor's schema/label/description * but run our code — which emits `acrossai_mcp_is_ability_exposed` with * the current server_id. * * The `CurrentServerHolder` singleton is populated during * `rest_pre_dispatch` (priority 5) when the incoming REST route matches a * registered MCP server, and cleared during `rest_post_dispatch` / * `shutdown` (priority 999). Our replacement callbacks read from it via * `AbilityHelpers::apply_exposure_filter()`. * * Supersedes the previous vendor-abilities interceptor module (deleted * in the same change — see git history for the prior post-hoc approach). */ $callback_replacer = \AcrossAI_MCP_Manager\Includes\Abilities\CallbackReplacer::instance(); $this->loader->add_filter( 'wp_register_ability_args', $callback_replacer, 'replace_callbacks', 10, 2 ); /** * Feature 090 — the diagnostic ability shown when a server type's * requirement is unmet. * * Registered unconditionally and Loader-wired per A1. It reaches a * client only by being placed in a specific server's tool list by * `ToolPolicy::compose_effective_tools_for_row()`, so registering it on * a healthy install costs nothing and avoids a presence probe whose * timing would depend on plugin load order. * * Priority 20 — after CallbackReplacer (10) has finished rebinding the * three vendor meta-tools, so the two never contend. */ // The category MUST be registered before any ability is assigned to it — // WP 6.9+ emits _doing_it_wrong otherwise. Core fires the categories hook // ahead of wp_abilities_api_init, so this ordering is the platform's. $this->loader->add_action( 'wp_abilities_api_categories_init', \AcrossAI_MCP_Manager\Includes\Abilities\SetupRequired::class, 'register_category', 10 ); $this->loader->add_action( 'wp_abilities_api_init', \AcrossAI_MCP_Manager\Includes\Abilities\SetupRequired::class, 'register', 20 ); // The MCP Adapter server type's guide. Priority 20 for the same reason // as SetupRequired: after CallbackReplacer (10) has finished rebinding // the vendor meta-tools, so nothing it describes is still in flux. $this->loader->add_action( 'wp_abilities_api_init', \AcrossAI_MCP_Manager\Includes\Abilities\ServerGuide::class, 'register', 20 ); // The Toolset dispatchers this plugin owns. `register()` only CONSTRUCTS // them — each constructor attaches its own `wp_abilities_api_init` hook // at 20 — so what matters here is running before the abilities registry // first resolves. That is NOT a fixed moment: core fires // `wp_abilities_api_init` from WP_Abilities_Registry::get_instance(), // so any plugin touching the registry during `plugins_loaded` brings it // forward. `init` would be too late in that case and the dispatchers // would silently never attach. // // Priority 21 is also what orders the changeover. The add-on may still // carry these same Toolsets; it constructs its copies at // `plugins_loaded` 20, so running at 21 means its hooks are always // attached first, its copies always register first, and ours always // stand down — without depending on which plugin WordPress happens to // load first. See Toolset\Registrar for why both shipping at once is // deliberate. $this->loader->add_action( 'plugins_loaded', \AcrossAI_MCP_Manager\Includes\Abilities\Toolset\Registrar::class, 'register', 21 ); // The `toolset` ability CATEGORY every dispatcher above assigns itself // to. It must exist before any of them registers, or each registration // is rejected as referring to an unknown category — which is why this // is a separate hook rather than something Registrar does: core fires // `wp_abilities_api_categories_init` first, by design, for exactly this. // The registrar no-ops when the add-on already declared the category. $this->loader->add_action( 'wp_abilities_api_categories_init', \AcrossAI_MCP_Manager\Includes\Abilities\Toolset\Category_Registrar::instance(), 'register' ); /** * Feature 030 — per-server ability permission_callback override. * * Wraps every ability's permission_callback in a closure that returns * `true` iff (a) MCP request in flight, (b) that server's operator * toggled `override_abilities_permission = 1`, (c) the slug is exposed * to this server via wp_acrossai_mcp_server_abilities. Otherwise the * original callback runs unchanged. * * Priority 999999 — MUST beat sibling acrossai-abilities-manager's * P100000 injector so the operator toggle wins. Load-bearing invariant * per DEC-F030-PERMISSION-CALLBACK-OPERATOR-OPT-IN-BYPASS. * * Companion cache-clear on rest_post_dispatch + shutdown mirrors A17 * pattern (symmetric with CurrentServerHolder::clear). */ $permission_override = \AcrossAI_MCP_Manager\Includes\Abilities\PermissionOverrideProcessor::instance(); $this->loader->add_filter( 'wp_register_ability_args', $permission_override, 'inject_override', \AcrossAI_MCP_Manager\Includes\Abilities\PermissionOverrideProcessor::PRIORITY, 2 ); $this->loader->add_filter( 'rest_post_dispatch', $permission_override, 'clear_request_cache', 999, 1 ); $this->loader->add_action( 'shutdown', $permission_override, 'clear_request_cache', 999 ); /** * Per-server exposure for abilities reached through a sibling Toolset. * * The Toolsets in acrossai-abilities-manager are a fourth route into the * ability catalogue, alongside the three plugin-owned meta tools. Without * this bridge the Abilities tab's per-server toggles are silently * bypassed by every `toolset-*` call — see ToolsetExposureBridge. * * Registered unconditionally: the filter simply never fires when the * sibling is inactive, so no class_exists guard is needed. */ $toolset_exposure = \AcrossAI_MCP_Manager\Includes\Abilities\ToolsetExposureBridge::instance(); $this->loader->add_filter( 'acrossai_toolset_member_visible', $toolset_exposure, 'filter_member_visible', 10, 4 ); // Keep our transport abilities out of the sibling's Toolsets entirely. // They belong to no group, and without this its tagger files them in the // catch-all — `toolset/other` was observed listing the server guide. $this->loader->add_filter( 'acrossai_abilities_manager_protected_slugs', \AcrossAI_MCP_Manager\Includes\Abilities\ToolAbilities::class, 'protect_from_toolsets' ); $current_server_holder = \AcrossAI_MCP_Manager\Includes\Abilities\CurrentServerHolder::instance(); // `rest_pre_dispatch` is a filter that returns $result; priority 5 to // fire before any short-circuiting handlers at default 10. $this->loader->add_filter( 'rest_pre_dispatch', $current_server_holder, 'capture_from_request', 5, 3 ); $this->loader->add_filter( 'rest_post_dispatch', $current_server_holder, 'clear', 999, 1 ); // Safety net for fatal-error / early-exit paths. $this->loader->add_action( 'shutdown', $current_server_holder, 'clear', 999 ); // ───────────────────────────────────────────────────────────────── // F096 — the activity log's OAuth attribution holder. // // Same A17 three-stage lifecycle as CurrentServerHolder above, for the // same reason: stage 3 (`shutdown`) is what stops a fatal leaving a // resolved OAuth identity in memory for the NEXT request to be // attributed to. A17 calls omitting it the number-one silent bug in // ad-hoc versions of this pattern, and in an activity log the symptom // would be one client's calls credited to another. // // `capture_oauth_context` listens for the scoped action TokenValidator // fires after a successful bearer validation — hooks here per A1, // never inside the validator (Principle V). // ───────────────────────────────────────────────────────────────── $auth_context_holder = \AcrossAI_MCP_Manager\Includes\Observability\AuthContextHolder::instance(); $this->loader->add_action( \AcrossAI_MCP_Manager\Includes\Utilities\ActivityLogConstants::ACTION_OAUTH_TOKEN_VALIDATED, $auth_context_holder, 'capture_oauth_context', 10, 2 ); $this->loader->add_filter( 'rest_pre_dispatch', $auth_context_holder, 'reset_for_request', 5, 3 ); $this->loader->add_filter( 'rest_post_dispatch', $auth_context_holder, 'clear', 999, 1 ); $this->loader->add_action( 'shutdown', $auth_context_holder, 'clear', 999 ); // F096 — the activity log's REST surface and privacy callbacks. // // The exporter and eraser ship in THIS release rather than with the // governance story, because this release is what starts recording source // addresses. Clarification Q1 kept the full address on the condition // that the subject-data path accompanied it; deferring the callbacks // would keep the cost and drop the mitigation (SEC-T02). $this->loader->add_action( 'rest_api_init', \AcrossAI_MCP_Manager\Includes\REST\ActivityLogController::instance(), 'register_routes' ); $this->loader->add_filter( 'wp_privacy_personal_data_exporters', \AcrossAI_MCP_Manager\Includes\Observability\PersonalData::class, 'register_exporter' ); $this->loader->add_filter( 'wp_privacy_personal_data_erasers', \AcrossAI_MCP_Manager\Includes\Observability\PersonalData::class, 'register_eraser' ); // The Activity screen: its own submenu under the shared AcrossAI parent, // not under tools.php. The asset enqueue is gated on the page hook, so // the 334 KB bundle never loads on an unrelated admin screen. $activity_page = \AcrossAI_MCP_Manager\Admin\Partials\ActivityLogPage::instance(); $this->loader->add_action( 'admin_menu', $activity_page, 'register_menu', 20 ); $this->loader->add_action( 'admin_enqueue_scripts', $activity_page, 'enqueue_assets' ); // #181 — purge cached discovery documents when what they describe // changes. BerlinDB fires `transition_mcp_server_{column}` from // update_item(), so hooking the transition covers every writer — the // admin form, REST, the seeder, WP-CLI, a companion plugin — rather // than the four save paths that happen to exist today. $discovery_invalidator = \AcrossAI_MCP_Manager\Includes\OAuth\DiscoveryCacheInvalidator::class; foreach ( $discovery_invalidator::watched_columns() as $watched_column ) { $this->loader->add_action( 'transition_mcp_server_' . $watched_column, $discovery_invalidator, 'on_change', 10, 3 ); } $this->loader->add_action( 'mcp_server_deleted', $discovery_invalidator, 'on_delete', 10, 2 ); // F101 — the connection troubleshooter. Its own submenu at position 9, // directly after Logs: both are screens an operator opens when // something is already wrong, and the diagnostics belong next to the // record of what happened. The REST route is admin-only and runs // nothing on page load — every check makes an outbound HTTP request, // so the operator presses a button. $troubleshoot_page = \AcrossAI_MCP_Manager\Admin\Partials\TroubleshootPage::instance(); $this->loader->add_action( 'admin_menu', $troubleshoot_page, 'register_menu', 20 ); $this->loader->add_action( 'admin_enqueue_scripts', $troubleshoot_page, 'enqueue_assets' ); $this->loader->add_action( 'rest_api_init', \AcrossAI_MCP_Manager\Includes\REST\TroubleshootController::instance(), 'register_routes' ); // F096 — ability capture. // // Core instruments WP_Ability::execute() for EVERY caller, so these // hooks cover MCP, the REST abilities route, WP-CLI and the admin UI // without any callback wrapping (which would duplicate core's own seam // and still miss non-MCP callers). // // The 7.1 quartet is feature-detected, NOT required: the plugin floor // is 6.9 and those hooks are @since 7.1.0. On 6.9 the guaranteed pair // records successes only — verified in core at class-wp-ability.php:634, // where a WP_Error early-returns before `wp_after_execute_ability` // fires, and at :615, where a permission denial returns before the // `before` hook fires at all. The always-on bracket in // Base_Toolset_Ability / Abilities\Execute is what covers failures and // denials for MCP-originated calls on every supported version. $ability_recorder = \AcrossAI_MCP_Manager\Includes\Abilities\AbilityActivityRecorder::instance(); $this->loader->add_action( 'wp_before_execute_ability', $ability_recorder, 'on_before_execute', 10, 2 ); $this->loader->add_action( 'wp_after_execute_ability', $ability_recorder, 'on_after_execute', 10, 3 ); if ( \AcrossAI_MCP_Manager\Includes\Abilities\AbilityActivityRecorder::has_rich_hooks() ) { $this->loader->add_filter( 'wp_ability_execute_result', $ability_recorder, 'on_execute_result', 10, 3 ); $this->loader->add_filter( 'wp_ability_permission_result', $ability_recorder, 'on_permission_result', 10, 3 ); $this->loader->add_filter( 'wp_ability_validate_input', $ability_recorder, 'on_validate_input', 10, 3 ); } // The attribution context 097 attaches to. Same A17 three-stage // lifecycle as the other two holders, for the same reason. $capture_context = \AcrossAI_MCP_Manager\Includes\Observability\CaptureContext::instance(); $this->loader->add_filter( 'rest_pre_dispatch', $capture_context, 'reset_for_request', 5, 3 ); $this->loader->add_filter( 'rest_post_dispatch', $capture_context, 'clear', 999, 1 ); $this->loader->add_action( 'shutdown', $capture_context, 'clear', 999 ); $this->loader->add_filter( 'rest_post_dispatch', $ability_recorder, 'clear', 999, 1 ); $this->loader->add_action( 'shutdown', $ability_recorder, 'clear', 999 ); // F096 — response-size measurement (context cost). // // The observability seam passes tags and a duration and NEVER the result // payload, so a response's SIZE is unreachable from the handler. These // seven published vendor filters do see it, and are hooked purely to // take a strlen() — no payload is retained, so there is nothing to // redact and no truncation question. // // `mcp_adapter_tools_list` is the load-bearing one: tools/list has no // ability children, so its size cannot be recovered by rolling up // parent_id, and the tool catalogue injected at connect is usually the // largest single context cost a server imposes. // // Every callback returns its input unchanged. Priority 10 with the exact // arg counts the vendor passes. $size_probe = \AcrossAI_MCP_Manager\Includes\Observability\ResponseSizeProbe::instance(); $this->loader->add_filter( 'mcp_adapter_tool_call_result', $size_probe, 'measure_tool_call', 10, 3 ); $this->loader->add_filter( 'mcp_adapter_tools_list', $size_probe, 'measure_tools_list', 10, 1 ); $this->loader->add_filter( 'mcp_adapter_initialize_response', $size_probe, 'measure_initialize', 10, 1 ); $this->loader->add_filter( 'mcp_adapter_prompt_get_result', $size_probe, 'measure_prompt_get', 10, 3 ); $this->loader->add_filter( 'mcp_adapter_prompts_list', $size_probe, 'measure_prompts_list', 10, 1 ); $this->loader->add_filter( 'mcp_adapter_resource_read_result', $size_probe, 'measure_resource_read', 10, 3 ); $this->loader->add_filter( 'mcp_adapter_resources_list', $size_probe, 'measure_resources_list', 10, 1 ); // F096 — the daily retention + ceiling sweep, and a re-sync whenever the // bounds change so the schedule follows the settings rather than // drifting from them until the next activation. $activity_cleanup = \AcrossAI_MCP_Manager\Includes\Database\ActivityLog\Cleanup::instance(); $this->loader->add_action( \AcrossAI_MCP_Manager\Includes\Database\ActivityLog\Cleanup::CRON_HOOK, $activity_cleanup, 'run' ); // Reconcile the schedule on a normal boot, not only on activation and on // a settings save. B68 again, and the default flip is what made it // dangerous: WordPress skips activation hooks on a plugin UPDATE, and an // operator who is happy with the shipped defaults never saves the // settings form — so neither of the two paths that call // `sync_schedule()` would ever run. While recording was OFF by default // that was harmless: nothing was written, so a missing pruning job // pruned nothing. Now that both halves record by default, the same gap // means a site logs from its first request with NOTHING ever deleting a // row. Priority 4, so the table reconciliation at 3 has already run. $this->loader->add_action( 'admin_init', \AcrossAI_MCP_Manager\Includes\Database\ActivityLog\Cleanup::class, 'sync_schedule', 4 ); foreach ( array( \AcrossAI_MCP_Manager\Includes\Utilities\ActivityLogConstants::OPTION_RETENTION_REQUEST_DAYS, \AcrossAI_MCP_Manager\Includes\Utilities\ActivityLogConstants::OPTION_RETENTION_ABILITY_DAYS, \AcrossAI_MCP_Manager\Includes\Utilities\ActivityLogConstants::OPTION_MAX_EVENTS, ) as $bound_option ) { $this->loader->add_action( 'update_option_' . $bound_option, \AcrossAI_MCP_Manager\Includes\Database\ActivityLog\Cleanup::class, 'sync_schedule' ); $this->loader->add_action( 'add_option_' . $bound_option, \AcrossAI_MCP_Manager\Includes\Database\ActivityLog\Cleanup::class, 'sync_schedule' ); } /** * Feature 040 — the OAuth admin REST controllers (ClientRegistration + ConnectorAdmin) * moved to acrossai-ai-connectors companion plugin. The companion registers * them under the same `acrossai-mcp-manager/v1` namespace so AI-client discovery * URLs stay unchanged. */ $this->loader->add_action( 'mcp_server_deleted', \AcrossAI_MCP_Manager\Includes\Database\MCPServerTool\Query::class, 'on_mcp_server_deleted', 10, 2 ); /** * F082 SEC-002 — Cascade cleanup of per-ability override rows on * server deletion. Subscribes to the same BerlinDB-level * `mcp_server_deleted` action as F020's cleanup above; both fire on * every server-row delete regardless of path (single-row or bulk * admin, REST, WP-CLI — all route through * `MCPServer\Query::delete_item()`). Mirrors F020's wiring shape. */ $this->loader->add_action( 'mcp_server_deleted', \AcrossAI_MCP_Manager\Includes\Database\MCPServerAbility\Query::class, 'on_mcp_server_deleted', 10, 2 ); // TODO (phase 5): wire REST\CliController. // $cli_controller = \AcrossAI_MCP_Manager\Includes\REST\CliController::instance(); // $this->loader->add_action( 'rest_api_init', $cli_controller, 'register_routes' ); } /** * Register all of the hooks related to the public-facing functionality * of the plugin. * * @since 0.0.1 * @access private */ private function define_public_hooks() { /** * F095 — OAuth bearer-token authenticator. * * Priority 20 matches what the companion used, so the resolution order * relative to other `determine_current_user` consumers is unchanged. * * The contract this upholds is fail-closed: every refusal path inside * `authenticate()` returns the INCOMING `$user_id` rather than a resolved * user or a literal. That is why the F095 upgrade window — companion * stood down, migration not yet run, token table empty — is an * availability problem and not an authentication bypass. Pinned by * TokenValidatorFailsClosedTest; do not "simplify" a branch to return 0. * * Resolved to a named variable first, per the constitution's Boot Flow * Rule: passing `Class::instance()` inline to add_filter is prohibited. */ $oauth_token_validator = \AcrossAI_MCP_Manager\Includes\OAuth\TokenValidator::instance(); $this->loader->add_filter( 'determine_current_user', $oauth_token_validator, 'authenticate', 20 ); /** * F095 — the rest of the OAuth surface. * * Every registration below was rehomed from the companion's own * `bootstrap_oauth_hooks()`. Per constitution A1 the classes themselves * register nothing; this is the only place their hooks are declared. * * Four of the companion's registrations are deliberately NOT reproduced: * `register_ai_connectors_tab`, `register_n8n_tab`, * `register_ai_connectors_method` and `register_n8n_method`. Those existed * only because an external plugin had to contribute tabs through a filter * and defer to `HostCapabilities::has_connect_tab()`. The tabs are ours * now, so they are seeded directly in Connect\MethodRegistry instead — * one indirection fewer, and no filter round-trip to reason about. */ // Stand down any competing `.well-known` implementation bundled by // another plugin. Priority 1 so the decision is settled before anything // else reads the discovery documents. $oauth_conflict_guard = \AcrossAI_MCP_Manager\Includes\OAuth\DiscoveryConflictGuard::instance(); $this->loader->add_action( 'plugins_loaded', $oauth_conflict_guard, 'take_over_discovery', 1 ); // Protocol endpoints. `register_rewrite_rules` MUST run on `init` and not // before: add_rewrite_rule() reaches $wp_rewrite, which is null earlier, // and the failure is a fatal rather than a warning (B42). $oauth_router = \AcrossAI_MCP_Manager\Includes\OAuth\OAuthRouter::instance(); $this->loader->add_action( 'init', $oauth_router, 'register_rewrite_rules' ); $this->loader->add_filter( 'query_vars', $oauth_router, 'add_query_var' ); $this->loader->add_action( 'parse_request', $oauth_router, 'parse_request' ); // Daily expiry sweep. Hook name is a contract preserved from before the // F040 split; Activator schedules it and Deactivator clears it. $oauth_cleanup = \AcrossAI_MCP_Manager\Includes\OAuth\Cleanup::instance(); $this->loader->add_action( 'acrossai_mcp_manager_oauth_cleanup', $oauth_cleanup, 'run' ); // Deleting a user removes their tokens and approvals. $oauth_user_lifecycle = \AcrossAI_MCP_Manager\Includes\OAuth\UserLifecycle::instance(); $this->loader->add_action( 'deleted_user', $oauth_user_lifecycle, 'on_user_deleted', 10 ); // RFC 6750 — a 401 must say how to authenticate. $oauth_bearer_challenge = \AcrossAI_MCP_Manager\Includes\OAuth\BearerChallengeHeader::instance(); $this->loader->add_filter( 'rest_post_dispatch', $oauth_bearer_challenge, 'add_bearer_challenge', 10, 3 ); // REST surface. $oauth_client_registration = \AcrossAI_MCP_Manager\Includes\OAuth\ClientRegistrationController::instance(); $this->loader->add_action( 'rest_api_init', $oauth_client_registration, 'register_routes' ); $oauth_connector_admin = \AcrossAI_MCP_Manager\Includes\OAuth\ConnectorAdminController::instance(); $this->loader->add_action( 'rest_api_init', $oauth_connector_admin, 'register_routes' ); // Revoking a user's approval cascades to their tokens (D32). Default-secure; // opt out via `acrossai_mcp_connector_revoke_tokens_on_approval_revoked`. $this->loader->add_action( 'acrossai_mcp_connector_user_approval_revoked', \AcrossAI_MCP_Manager\Includes\OAuth\ConnectorAdminController::class, 'cascade_revoke_tokens_on_approval_revoked', 10, 4 ); // Site Health. DiscoveryHealthCheck is what tells an operator a competing // plugin won the `.well-known` race (FR-016 / SEC-007). $oauth_discovery_health = \AcrossAI_MCP_Manager\Includes\OAuth\DiscoveryHealthCheck::instance(); $this->loader->add_filter( 'site_status_tests', $oauth_discovery_health, 'add_test' ); $oauth_timezone_health = \AcrossAI_MCP_Manager\Includes\Database\Support\TimezoneHealthCheck::instance(); $this->loader->add_filter( 'site_status_tests', $oauth_timezone_health, 'add_test' ); // F096 FR-013a. The Activity Log screen already says "recording is // failing" when the counter is non-zero; this is the half that works // without anyone opening that screen. Both are needed: the counter // resets on the next successful write, so an intermittent fault can // erase its own evidence before a human looks, and support cannot ask // an operator for a screen they have to navigate to. $recording_health = \AcrossAI_MCP_Manager\Includes\Observability\RecordingHealthCheck::instance(); $this->loader->add_filter( 'site_status_tests', $recording_health, 'add_test' ); // F096 FR-013b. `acrossai_mcp_schema_upgrade_failed` is fired by every // Table subclass in this plugin and, until this listener, went nowhere. // A signal with no listener is the same as no signal. $schema_health = \AcrossAI_MCP_Manager\Includes\Database\Support\SchemaFailureHealthCheck::instance(); $this->loader->add_action( 'acrossai_mcp_schema_upgrade_failed', $schema_health, 'record_failure', 10, 3 ); $this->loader->add_filter( 'site_status_tests', $schema_health, 'add_test' ); // The five built-in connector profiles. `ConnectorProfileRegistry` builds // its list purely from this filter, so without this line every surface // that lists connectors is empty — the AI Connectors tab, its Settings // checkboxes, and Quick Connect Step 10. The companion contributed them // from a closure gated on Freemius and a host probe; F095 dropped both // gates and rehomed the registration here per A1. $this->loader->add_filter( 'acrossai_mcp_manager_connector_profiles', \AcrossAI_MCP_Manager\Includes\Connectors\ConnectorProfileRegistry::class, 'register_builtin_profiles', 10, 1 ); // Bridge the registry into the Discovery API (F040 filter-inversion). // `ConnectionMethodRegistry` fires these two and contributes nothing // itself, so Quick Connect Step 10 depends on both: the first supplies // the connector tabs, the second the per-connector walkthrough HTML // (F082). Step 10 renders an empty state when either is unwired. $oauth_discovery_adapter = \AcrossAI_MCP_Manager\Includes\Discovery\DiscoveryConnectorAdapter::class; $this->loader->add_filter( 'acrossai_mcp_manager_discovery_ai_connectors', $oauth_discovery_adapter, 'provide_ai_connectors', 10, 1 ); $this->loader->add_filter( 'acrossai_mcp_manager_discovery_ai_connector_instructions', $oauth_discovery_adapter, 'provide_ai_connector_instructions', 10, 1 ); /** * Phase 6 — REST CLI Authentication Controller + Phase 6.0 FrontendAuth. * * Every CLI-flow hook trace MUST be in this method — feature classes * never call add_action / add_filter themselves (A1 / FR-021). */ $frontend_auth = \AcrossAI_MCP_Manager\Public\Partials\FrontendAuth::instance(); $this->loader->add_action( 'init', $frontend_auth, 'register_rewrite_rule' ); $this->loader->add_filter( 'query_vars', $frontend_auth, 'add_query_var' ); $this->loader->add_action( 'template_redirect', $frontend_auth, 'maybe_render_page', 10 ); $this->loader->add_action( 'wp_enqueue_scripts', $frontend_auth, 'enqueue_assets' ); $cli_controller = \AcrossAI_MCP_Manager\Includes\REST\CliController::instance(); $this->loader->add_action( 'rest_api_init', $cli_controller, 'register_routes' ); /** * Feature 013 — Public Renderer layer. * * Wires the REST endpoint + 2 shortcodes for the public client-config * renderers (NpmClientBlock, MCPClientsBlock). * Third-party plugins (BuddyBoss, WooCommerce, etc.) consume via: * - do_action( 'acrossai_mcp_render_client_block', $slug, $server_id, $context ) * - apply_filters( 'acrossai_mcp_client_block_context', $context, $slug, $server_id ) * - apply_filters( 'acrossai_mcp_client_classes', $default_fqns ) * - Shortcodes: [acrossai_mcp_npm_block server=X], etc. * All @experimental until 1.0.0 per DEC-CLIENT-RENDERER-PUBLIC-API. */ $client_renderer_rest = \AcrossAI_MCP_Manager\Includes\REST\ClientRendererController::instance(); $this->loader->add_action( 'rest_api_init', $client_renderer_rest, 'register_rest_routes' ); $this->loader->add_action( 'init', $client_renderer_rest, 'register_shortcodes_and_actions' ); /** * OAuth 2.1 stack (OAuthRouter, Cleanup, TokenValidator, UserLifecycle, * BearerChallengeHeader) moved to acrossai-ai-connectors companion plugin * (Feature 040). Companion registers identical hooks under the same * namespace (`acrossai-mcp-manager/v1`) and cron hook name * (`acrossai_mcp_manager_oauth_cleanup`), so existing bearer tokens * and discovery URLs continue to work when the companion is active. */ /** * Feature 038's `[acrossai_mcp_servers]` shortcode was registered * here. It moved to acrossai-pro along with the F037 embed transport * subsystem it read through; see that plugin's * `Includes\McpEmbeds\UserServers`. * * Both halves had to move together. The shortcode rendered only * servers whose F037 embed toggles were on, and the Embeds tab — the * only UI that ever wrote those toggles — had already gone to Pro in * 0.11.0, leaving this registration able to render nothing but an * empty string. D40's composition rule travelled with the code * unchanged; see that decision's amendment note. */ /** * Per-server meta cleanup on server deletion. * * When a server row is deleted (via Settings::handle_actions() * `action=delete`), prune every meta row that belonged to it. Fires as * a listener on the `acrossai_mcp_server_deleted` action defined in * Settings::handle_actions(). * * This arrived with F037 and outlived it. The meta table is generic * per-server storage owned by this plugin — acrossai-pro's embed * feature is one tenant of it, not the reason it exists — so the * listener stays here after the embed subsystem's departure. The * callback's name is the F037-era one, kept because it is public. * * Wired via a closure per D17 A1-transitivity — the Loader-wired * action fire happens here; the cleanup logic sits inline as a * one-line delegation to the Query class. */ $this->loader->add_action( 'acrossai_mcp_server_deleted', $this, 'cleanup_embed_transports_on_server_delete', 10 ); } /** * Prune every meta row for a deleted server. Called by the * `acrossai_mcp_server_deleted` listener wired in `define_public_hooks()`. * * Deletes EVERY meta row for the server, not just the `_embeds_enabled` / * `_embeds_clients` pair it was originally written for — the meta table is * generic per-server storage, and server deletion should orphan zero meta * rows whichever plugin wrote them. * * @param int $server_id Deleted server primary key. * @return void */ public function cleanup_embed_transports_on_server_delete( int $server_id ): void { if ( $server_id <= 0 ) { return; } \AcrossAI_MCP_Manager\Includes\Database\MCPServerMeta\Query::delete_by_server_id( $server_id ); } /** * Run the loader to execute all of the hooks with WordPress. * * @since 0.0.1 */ public function run() { $this->loader->run(); } /** * The name of the plugin used to uniquely identify it within the context of * WordPress and to define internationalization functionality. * * @since 0.0.1 * @return string The name of the plugin. */ public function get_plugin_name() { return $this->plugin_name; } /** * The reference to the class that orchestrates the hooks with the plugin. * * @since 0.0.1 * @return Loader Orchestrates the hooks of the plugin. */ public function get_loader() { return $this->loader; } /** * The reference to the autoloader instance. * * @since 0.0.1 * @return Autoloader The plugin autoloader instance. */ public function get_autoloader() { return $this->autoloader; } /** * Retrieve the version number of the plugin. * * @since 0.0.1 * @return string The version number of the plugin. */ public function get_version() { return $this->version; } }