*/ public static function all(): array { /** * Filter the registered MCP server types. * * Dedup is slug-keyed LAST-WINS (D41): a later registration REPLACES an * earlier one with the same slug, including this plugin's built-ins. * That is precisely how the sibling overrides the `acrossai` * placeholder below with its real toolsets. * * @since 0.1.0 (Feature 090) * * @param array> $types Types keyed by slug. */ $types = apply_filters( self::FILTER, self::seed() ); return self::normalize( is_array( $types ) ? $types : array() ); } /** * One type by slug, or null when it is not registered. * * Returning null rather than falling back is deliberate: callers that need * a usable tool list call `tools_for()`, which degrades; callers that need * to know whether the slug is real check this. * * @since 0.1.0 * @param string $slug Type slug. * @return array|null */ public static function get( string $slug ): ?array { $all = self::all(); return $all[ $slug ] ?? null; } /** * The tool slugs a type starts with. * * An unrecognised slug degrades to the legacy type's tools rather than * returning an empty set — a server whose stored type was removed with its * plugin must stay usable, not silently lose every tool. * * @since 0.1.0 * @param string $slug Type slug. * @return string[] */ public static function tools_for( string $slug ): array { $all = self::all(); $type = $all[ $slug ] ?? null; // Unrecognised slug, OR a registered type that contributes no tools of // its own. Both fall back to the legacy set. // // The empty case is load-bearing, not theoretical. This plugin ships // `acrossai` as a PLACEHOLDER with `tools => []`, expecting the sibling // to replace it (D41 last-wins). Between the sibling being ACTIVE (so // `is_available()` is true) and the sibling actually registering its // type, the placeholder is what resolves — and a template of `[]` would // make Reset WIPE every tool on that server. That is strictly worse than // the hardcoded-defaults bug this feature exists to fix. // // A server type that offers nothing is never a useful template, so // returning the legacy set is right regardless of how the empty arose. if ( null === $type || empty( $type['tools'] ) ) { return self::registered_only( $all[ self::LEGACY ]['tools'] ?? array() ); } $tools = self::registered_only( $type['tools'] ); // The same wipe, reached the other way. A type can declare tools that // are all unregistered — every `toolset/*` slug on a site without the // AcrossAI Abilities Manager add-on, for instance — and the declaration // check above sees a healthy list while the narrowing leaves nothing. // Reset would then write that nothing over the operator's curation. // // Guarding the RESULT rather than only the declaration closes both // routes at once, and closes them against a third-party type too. An // empty template is never a useful one, however it got that way. if ( array() === $tools ) { return self::registered_only( $all[ self::LEGACY ]['tools'] ?? array() ); } return $tools; } /** * A type's tool list as DECLARED, without narrowing to what is registered. * * This is the one to WRITE. `tools_for()` answers "what could this type * serve right now?", which is the right question for a reader and the wrong * one for anything that stores a template: on a site without the AcrossAI * Abilities Manager add-on every `toolset/*` slug narrows away, so a type * declaring fifteen tools resolves to the legacy four and stamps them onto * an AcrossAI server. * * That is the original reported bug — a new AcrossAI server arriving with * mcp-adapter's tools — and until 0.3.6 it survived in both writers: * `ToolPolicy::apply_type_defaults()` on create, and the Reset template. * The seeder already avoided it by reading the declaration directly * ({@see DefaultServerSeeder::write_declared_tools()}); this gives the other * two the same answer instead of each re-deriving it. * * Writing slugs whose abilities do not exist yet is deliberate. They are * never advertised to a client, and they light up when the add-on registers * them — no type switch, no Reset. * * Keeps `tools_for()`'s fallback for an unrecognised or genuinely empty * type, because an empty template still wipes a server whichever accessor * produced it. * * @since 0.3.6 * @param string $slug Type slug. * @return string[] */ public static function declared_tools( string $slug ): array { $all = self::all(); $type = $all[ $slug ] ?? null; $tools = ( null === $type || empty( $type['tools'] ) ) ? self::registered_only( $all[ self::LEGACY ]['tools'] ?? array() ) : array_values( (array) $type['tools'] ); // Filtered on BOTH paths, including the legacy fallback. A server whose // type was removed with its plugin is exactly the case where a // contributor still wants its toolset restored. return self::filter_declared_tools( $tools, $slug ); } /** * Let other plugins add to — or remove from — what a type restores. * * Runs on the DECLARED list, so a contribution reaches every writer at once: * Reset to Type Defaults, server creation ({@see ToolPolicy::apply_type_defaults()}), * a type switch, and the backfill. {@see self::pool()} also builds on this, * so an added toolset is OFFERED in the Tools tab's picker rather than * appearing only after a Reset. * * Not narrowed to registered abilities, matching the rest of this method: * declaring a slug whose plugin is not installed yet is deliberate, and * {@see self::registered_only()} does the narrowing wherever it matters. * * The empty guard is the same one the declaration and result checks above * enforce, for the same reason: Reset WRITES this list, so returning nothing * wipes the operator's curation. A filter that empties the list is treated * as a mistake and the unfiltered list is kept — a contributor that really * means to strip a type down to one tool can still return that one tool. * * @since 0.4.8 * * @param string[] $tools Declared tool slugs before filtering. * @param string $slug Server type slug. * @return string[] */ private static function filter_declared_tools( array $tools, string $slug ): array { /** * Filter the tool slugs a server type declares. * * Additive by convention — append your own slugs and return the array. * Returning an empty array is ignored, because this list is written over * a server's curated tools. * * @since 0.4.8 * * @param string[] $tools Declared tool slugs, in order. * @param string $slug Server type slug, e.g. `acrossai-fleet`. */ /** * Deliberately widened to mixed. The WordPress stubs infer a filter's * return from what was passed in, so PHPStan reads this as `string[]` * and calls the guards below unreachable — but the callbacks are * third-party code and can return anything at all. The stub describes * the contract; this describes what actually arrives. * * @var mixed $filtered */ $filtered = apply_filters( self::TOOLS_FILTER, $tools, $slug ); if ( ! is_array( $filtered ) ) { return $tools; } $clean = array(); foreach ( $filtered as $tool ) { // Scalars only, and no blanks. A filter returning an array or object // member would otherwise reach the write path and fail there, well // away from the plugin that produced it. if ( ! is_string( $tool ) ) { continue; } $tool = trim( $tool ); if ( '' !== $tool ) { $clean[] = $tool; } } $clean = array_values( array_unique( $clean ) ); return array() === $clean ? $tools : $clean; } /** * Every tool a server may offer — the picker's pool, server-side. * * ALL tool-level abilities registered on this site, narrowed only to those * that actually exist. Deliberately NOT scoped by server type. * * An earlier version subtracted any tool claimed by a DIFFERENT type, so an * AcrossAI server was never offered the three `mcp-adapter/*` protocol tools * and an MCP Adapter server was never offered the toolsets. That made a type * behave as a FILTER over the picker, which contradicts what a type is: * * > A type IS a named template — "start this server with these tools". * > A type is NOT a runtime filter over what the server serves. * > — docs/extending-server-types.md §1 * * SCOPED TO THE TYPE since 0.3.6, reversing the original decision. The pool * used to be every tool-level ability on the site, on the reasoning that a * type decides what Reset WRITES and not what an operator may add by hand. * Defensible in the abstract; wrong on screen. An AcrossAI server offered * the four `mcp-adapter/*` tools — a different server's entire vocabulary, * under a heading naming this one — and the header counted the configured * set against a pool that excluded it, reading "15 of 4". * * A tool-level ability that NO type claims is still offered everywhere, so * the `acrossai_mcp_manager_tool_abilities` filter keeps working for a * plugin contributing a tool of its own. Only vocabulary another type has * claimed is withheld — which is exactly the case that looked wrong. * * A plugin that wants its tool on a particular type declares it there * (`acrossai_mcp_server_types`); one that wants it on a particular SERVER * still has `acrossai_mcp_manager_server_tools`. Neither route is closed. * * The type's own tools are NOT narrowed to what is registered. A declared * tool whose plugin is not installed yet belongs in the pool: the Tools tab * shows it as pending, and hiding it would make the pool disagree with the * list beside it. Unclaimed third-party slugs ARE narrowed — with no type * vouching for one, an unregistered slug is just junk. * * This is what the Tools tab's picker offers on the left, and what its * "Enable All" writes: a tool registered by a plugin installed tomorrow * appears in the pool, ready to be added. It is offered, not auto-added — * the operator's curated rows are the only thing a server serves. * * Single source of truth for the pool: the Tools tab renders from this rather * than recomputing anything in JavaScript, so the picker can never offer * something the write path would reject. * * NOTE `registered_only()` exempts `ToolPolicy::PROTOCOL_TOOLS` (B62). Without * that the three built-ins would be filtered straight back out here, because * `wp_get_abilities()` cannot see them in the Tools tab's REST context. * * @since 0.1.0 * @param string $type_slug Scope to this type. Empty keeps the old * site-wide answer, for callers with no server in * hand. * @return string[] */ public static function pool( string $type_slug = '' ): array { // The setup diagnostic is tool-level, so it is in `get_slugs()` — that // is what hides it from the Abilities tab and exempts it from the // exposure gate. It must never be OFFERED, though: the plugin puts it // in front of a stranded client by itself, and an operator curating it // onto a healthy server would advertise a repair notice for a problem // that server does not have. // // Excluded here rather than left out of `get_slugs()`, because this is // the layer that answers "what may be added to this server" and that is // the only question it fails. $declared = array_values( array_diff( ToolAbilities::get_slugs(), array( SetupRequired::SLUG ) ) ); if ( '' === $type_slug ) { return self::registered_only( $declared ); } $claimed = array(); foreach ( self::all() as $type ) { $claimed = array_merge( $claimed, (array) $type['tools'] ); } return array_values( array_unique( array_merge( self::declared_tools( $type_slug ), self::registered_only( array_values( array_diff( $declared, $claimed ) ) ) ) ) ); } /** * Narrow declared tools to abilities that actually exist on this site. * * A type declares what it WANTS; this site decides what EXISTS. A companion * plugin registers one dispatcher per area it covers, but a dispatcher whose * group has no members never registers an ability — so a site without, say, * GeoDirectory still sees `toolset/geodirectory` declared. * * Left unfiltered this breaks two things: the Tools tab's write is refused * with "One or more submitted ability slugs are not registered on this site", * and Reset writes slugs whose abilities no longer exist. * * Filtering belongs HERE rather than in the contributing plugin. A * contributor declares its slugs while abilities are still being assembled * and cannot know which will survive; by the time anything ASKS for a type's * tools the registry is populated, so this can simply look. * * Bails when the registry is empty rather than returning nothing — an empty * registry means abilities have not been registered yet, not that every * declared tool is invalid, and returning `array()` would make Reset wipe the * server. * * @since 0.1.0 * Public because `ToolPolicy` needs the same narrowing for a server's CURATED * rows: a presence row outlives the plugin that registered its ability, so a * deactivated plugin leaves rows naming abilities that no longer exist. * * @param string[] $slugs Declared tool slugs. * @return string[] Those that are registered abilities. */ public static function registered_only( array $slugs ): array { if ( ! function_exists( 'wp_get_abilities' ) ) { return $slugs; } $registered = \wp_get_abilities(); if ( empty( $registered ) ) { return $slugs; } $names = array(); foreach ( $registered as $ability ) { $names[ (string) $ability->get_name() ] = true; } // The three protocol tools are ALWAYS considered registered, whatever // the registry currently says. // // The vendor registers them on `wp_abilities_api_init`, but attaches its // listener inside `Controller::initialize_adapter()` (rest_api_init) — // which runs AFTER `wp_abilities_api_init` has already fired on any // request whose Abilities-API bootstrap ran on `init`. So // `wp_get_abilities()` is blind to them in exactly the context the Tools // tab runs in. `ToolPolicy::PROTOCOL_TOOL_METADATA` exists for the same // reason and records the same finding. // // Without this, `tools_for()`'s legacy fallback resolved to an EMPTY set // whenever the registry was non-empty but lacked the protocol slugs, and // Reset on an unknown or empty-template type WIPED the server — the // precise failure the fallback was written to prevent, defeated by the // narrowing added alongside it. Caught by ServerTypesTest on its first // CI run; `empty( $registered )` alone never covered it, because the // registry is populated, just not with these. $names += array_fill_keys( ToolPolicy::PROTOCOL_TOOLS, true ); return array_values( array_filter( $slugs, static function ( string $slug ) use ( $names ): bool { return isset( $names[ $slug ] ); } ) ); } /** * Whether a type's requirement is satisfied. * * THE single resolver for "requirement met". Selection, the enablement gate * and the runtime tool composition all call this; none re-derives the rule. * B32: a filter default that gates a decision must be the canonical * resolver's output, never a partial derivation. * * An unregistered slug is NOT available — it cannot be shown to work. * * @since 0.1.0 * @param string $slug Type slug. * @return bool */ public static function is_available( string $slug ): bool { $type = self::get( $slug ); if ( null === $type ) { return false; } if ( null === $type['requires'] || '' === $type['requires'] ) { return true; } return self::plugin_is_active( $type['requires'] ); } /** * The type preselected for newly created servers. * * Resolves to the LAST entry flagged `is_default` **whose requirement is * satisfied**, so a site without the sibling never defaults to a type it * cannot use. `mcp-adapter` is the floor and is always registered. * * As shipped that means `acrossai` on a site carrying the add-on and * `mcp-adapter` everywhere else — both entries declare the flag, and the * requirement check is what picks between them. Changing the preferred * type is moving the flag in `seed()`; every create path reads this. * * @since 0.1.0 * @return string */ public static function default_slug(): string { $resolved = self::LEGACY; foreach ( self::all() as $slug => $type ) { if ( $type['is_default'] && self::is_available( $slug ) ) { $resolved = $slug; } } return $resolved; } /** * The HARD case — a type slug nothing recognises, which can never work. * * Narrowed in 0.3.6. This used to refuse BOTH an unrecognised slug and a * known type whose required plugin was inactive; the second is now reported * by {@see self::requirement_notice()} and no longer blocks enabling. The * two are different questions: one asks "could this ever work?", the other * "does it work yet?", and answering both with a refusal made the second * unanswerable — Quick Connect cannot complete against a server it may not * switch on, which is the wizard the operator without the add-on is * standing in. * * Keeping the unrecognised-slug refusal is what stops this becoming a gate * that never says no. * * Returns a `WP_Error` the caller renders in its own surface. Consumed * exclusively by `ServerEnablement::set()` — see that class for why * enforcement lives behind one facade rather than at each call site. * * @since 0.1.0 * @param string $slug Type slug. * @return WP_Error|null */ public static function enablement_error( string $slug ): ?WP_Error { if ( null !== self::get( $slug ) ) { return null; } return new WP_Error( 'acrossai_mcp_unknown_server_type', sprintf( /* translators: %s: the server type slug stored on the row. */ esc_html__( 'This server has an unrecognised type (%s). Change its type before enabling it.', 'acrossai-mcp-manager' ), $slug ), array( 'status' => 400 ) ); } /** * The SOFT case — a known type whose required plugin is not active. * * Split out of `enablement_error()` because the two answer different * questions and deserve different answers. An unrecognised slug can never * be made to work, so enabling stays refused. A known type missing its * plugin is a temporary state the operator can resolve, and refusing it * breaks the main setup path for exactly the person who has not installed * the add-on yet: Quick Connect cannot complete against a server it is * forbidden to switch on. * * So enabling is ALLOWED and this reports what is still missing. The server * is then Enabled but not Ready — `is_enabled` records the operator's * INTENT, and this condition decides whether that intent can take effect. * Install the plugin and the server runs with no further action, because * the intent was already recorded. * * The runtime half already behaves: `ToolPolicy` resolves such a server to * exactly `SetupRequired::SLUG`, so a connected client sees one * self-describing diagnostic instead of a broken tool list. The endpoint * deliberately keeps answering rather than 404ing — a client that suddenly * gets connection errors cannot tell you why; one that gets a single tool * saying what to install is told exactly what is wrong. * * @since 0.3.6 * @param string $slug Type slug stored on the row. * @return WP_Error|null Null when the type is unknown (see * `enablement_error()`) or its requirement is met. */ public static function requirement_notice( string $slug ): ?WP_Error { $type = self::get( $slug ); if ( null === $type || self::is_available( $slug ) ) { return null; } return new WP_Error( 'acrossai_mcp_server_type_unavailable', sprintf( /* translators: 1: server type label, 2: required plugin name. */ esc_html__( 'This server is enabled, but the %1$s type needs the %2$s plugin. Until it is installed and activated the server offers only a setup notice to AI clients.', 'acrossai-mcp-manager' ), $type['label'], self::required_plugin_label( (string) $type['requires'] ) ), array( 'status' => 400 ) ); } /** * The plugin FOLDER slug a type requires, or '' when it requires nothing. * * Exposed so a caller can act on the requirement rather than only print it * — Quick Connect feeds this to its install-plugin route, turning "you need * the add-on" into a button. * * @since 0.3.6 * @param string $slug Type slug. * @return string */ public static function required_plugin_slug( string $slug ): string { $type = self::get( $slug ); return null !== $type && null !== $type['requires'] ? (string) $type['requires'] : ''; } /** * A human name for a required plugin, falling back to its folder slug. * * The previous message named "AcrossAI Abilities Manager" unconditionally, * which is wrong the moment a third party registers a type with its own * `requires` — it would tell the operator to install the wrong plugin. * `get_plugins()` is admin-only, so the slug is the fallback rather than an * error: a folder name the operator can search for beats a confident lie. * * @since 0.3.6 * @param string $plugin_slug Plugin FOLDER slug from the type's `requires`. * @return string */ private static function required_plugin_label( string $plugin_slug ): string { if ( '' === $plugin_slug ) { return ''; } if ( self::ACROSSAI_REQUIRES === $plugin_slug ) { return __( 'AcrossAI Abilities Manager', 'acrossai-mcp-manager' ); } if ( ! function_exists( 'get_plugins' ) ) { return $plugin_slug; } foreach ( get_plugins() as $file => $data ) { if ( strtok( (string) $file, '/' ) === $plugin_slug && ! empty( $data['Name'] ) ) { return (string) $data['Name']; } } return $plugin_slug; } /** * The built-in types this plugin ships. * * @since 0.1.0 * @return array> */ private static function seed(): array { return array( self::LEGACY => array( 'label' => __( 'MCP Adapter', 'acrossai-mcp-manager' ), 'description' => __( 'The three built-in MCP tools. AI clients discover and run abilities through them.', 'acrossai-mcp-manager' ), // The three protocol tools PLUS the guide. Deliberately an array_merge // rather than a fourth PROTOCOL_TOOLS entry: that constant is // column-backed storage, and `ToolPolicy::split_payload()` diffs // against it while building columns from COLUMN_MAP alone — so a // fourth member with no `tool_*` column would be stripped from the // curated set AND have nowhere to be stored, silently dropping the // operator's pick on every save. 'tools' => array_merge( ToolPolicy::PROTOCOL_TOOLS, array( ServerGuide::SLUG ) ), // The FALLBACK default — what a new server gets on a site // without the add-on, where `acrossai` below is unavailable. // // DECLARED here rather than left to `default_slug()`'s LEGACY // floor, which would produce the same answer by absence. An // absence is invisible at the point the decision is made and is // reintroduced by accident the next time someone adds a type; // a key you can see is a key you can move. 'is_default' => true, // The server this plugin seeds for this type. Optional: a type // without it is selectable but seeds nothing. Adding a type AND // its server is now one entry in one place — see // `seeded_servers()` for why this key never reaches `all()`. 'server' => array( 'server_name' => 'Default MCP Server', 'server_slug' => DefaultServerSeeder::SLUG, 'description' => __( 'Default MCP server registered by the plugin.', 'acrossai-mcp-manager' ), 'registered_from' => 'plugin', 'server_route_namespace' => 'mcp', 'server_route' => DefaultServerSeeder::SLUG, 'server_version' => 'v1.0.0', ), ), self::ACROSSAI => array( 'label' => __( 'AcrossAI', 'acrossai-mcp-manager' ), 'description' => __( 'Abilities grouped into toolsets, supplied by the AcrossAI Abilities Manager add-on.', 'acrossai-mcp-manager' ), // DECLARED here since 0.3.6, where it used to be deliberately // empty for the sibling to fill in (D41 last-wins). // // The placeholder was correct while this plugin did not own the // vocabulary, and it produced the bug this release exists to // fix: a server created before the add-on arrived got an empty // menu, and only a type switch followed by "Reset to Type // Defaults" — four undocumented steps — ever filled it. Now the // Toolset layer lives here, so the list can be written down in // advance and the slugs sit dormant until the add-on arrives. // // Still NOT hardcoded vocabulary: `Toolset\Registrar` owns // these names, and this reads them. The sibling may still // re-register the slug and win (D41 unchanged). 'tools' => ToolsetRegistrar::tool_slugs(), 'requires' => self::ACROSSAI_REQUIRES, // The PREFERRED default — new servers are AcrossAI servers // wherever that is possible, matching the Recommended badge // this type's seeded server carries. // // Two entries carry `is_default` on purpose; that is what // `default_slug()` is built for. It takes the LAST flagged // entry WHOSE REQUIREMENT IS MET, and this one sits after // `mcp-adapter` and requires the add-on — so it wins on a site // that has the add-on, and yields to the floor above on a site // that does not. Neither case needs a second code path. 'is_default' => true, 'server' => array( 'server_name' => 'AcrossAI', 'server_slug' => DefaultServerSeeder::ACROSSAI_SLUG, 'description' => __( 'Recommended AcrossAI MCP server, managed by the plugin.', 'acrossai-mcp-manager' ), 'registered_from' => 'database', // `/acrossai/mcp`. The namespace already says whose server // this is, so repeating it in the route — `/acrossai/ // mcp-server` — only made the URL longer to type and read. // // Both halves are MANAGED columns, so a row created before // this change is corrected by the reconciler on the next // admin request rather than being left on the old address. // Safe to move because 0.3.6 is unreleased: the only rows // carrying the old route are test installs, and the server // ships disabled, so nothing was connected to it. 'server_route_namespace' => 'acrossai', 'server_route' => 'mcp', 'server_version' => 'v1.0.0', ), ), self::FLEET => array( 'label' => __( 'Fleet', 'acrossai-mcp-manager' ), 'description' => __( 'Lets WordPress sites you approve run abilities on this site.', 'acrossai-mcp-manager' ), // The same toolsets the AcrossAI type serves. A hub is not a // special kind of caller — it asks for exactly what a local AI // client would, so giving this route its own vocabulary would // mean maintaining two answers to the same question. 'tools' => ToolsetRegistrar::tool_slugs(), // The SAME requirement as the AcrossAI type, because it serves // the same toolsets — they come from acrossai-abilities-manager, // so without it this route has nothing to offer either. // // This was `null` for a while on the belief that declaring it // would make `SetupRequired` name the wrong plugin. It does // not: that message names acrossai-abilities-manager, which is // exactly what this type requires. The `null` bought nothing // and cost the availability check — the type claimed to be // available on a site where its tools could not exist. 'requires' => self::ACROSSAI_REQUIRES, // Never the default for a new server. An operator reaching for // "Add Server" wants something to connect an AI client to, and // this type is the one kind that cannot be connected to. 'is_default' => false, 'server' => array( 'server_name' => 'Fleet', 'server_slug' => DefaultServerSeeder::FLEET_SLUG, 'description' => __( 'Lets WordPress sites you approve run abilities on this site. Off until you add a host.', 'acrossai-mcp-manager' ), 'registered_from' => 'database', // `/acrossai/fleet`, deliberately NOT sharing `/acrossai/mcp`. // The separate route is what gives this row something to // govern: with it, the Logs tab holds hub traffic and // nothing else, Access Control scopes who may reach it, and // turning the server off stops hubs without touching the // site's own AI access. Sharing a route would leave three // tabs on this row governing nothing. 'server_route_namespace' => 'acrossai', 'server_route' => 'fleet', 'server_version' => 'v1.0.0', ), ), ); } /** * The built-in types that ask this plugin to seed a server, keyed by slug. * * Reads `seed()` directly and NOT `all()`, which is the security property * and not an oversight. `normalize()` emits a fixed five-key shape and * drops everything else, so the `server` key survives inside `seed()` but * never leaves `all()` — meaning a third-party plugin filtering * {@see self::FILTER} can contribute a server TYPE but can never make this * plugin create server ROWS. Do not "fix" that by widening `normalize()`. * * @since 0.3.6 * @return array, tools: string[]}> */ public static function seeded_servers(): array { $seeded = array(); foreach ( self::seed() as $type => $entry ) { if ( empty( $entry['server'] ) || ! is_array( $entry['server'] ) ) { continue; } $slug = isset( $entry['server']['server_slug'] ) ? (string) $entry['server']['server_slug'] : ''; if ( '' === $slug ) { continue; } $seeded[ $slug ] = array( 'type' => (string) $type, 'server' => $entry['server'], 'tools' => isset( $entry['tools'] ) ? array_values( (array) $entry['tools'] ) : array(), ); } return $seeded; } /** * Coerce filter output into the documented entry shape. * * A malformed contribution degrades to a dropped entry, never a fatal — * the same defensive posture `Controller::register_database_servers()` * applies to its own filters. * * `mcp-adapter` is re-asserted as the floor: a callback that removes it * cannot leave the site with no usable type. * * @since 0.1.0 * @param array $raw Filter output. * @return array */ private static function normalize( array $raw ): array { $normalized = array(); foreach ( $raw as $slug => $entry ) { $slug = sanitize_key( (string) $slug ); if ( '' === $slug || ! is_array( $entry ) ) { continue; } $label = isset( $entry['label'] ) ? (string) $entry['label'] : ''; if ( '' === $label ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { _doing_it_wrong( __METHOD__, sprintf( /* translators: 1: filter name, 2: type slug. */ esc_html__( '%1$s entry "%2$s" is missing a label and was dropped.', 'acrossai-mcp-manager' ), esc_html( self::FILTER ), esc_html( $slug ) ), '0.1.0' ); } continue; } $tools = isset( $entry['tools'] ) ? (array) $entry['tools'] : array(); $tools = array_map( 'strval', $tools ); $tools = array_filter( $tools, static function ( string $tool ): bool { return '' !== $tool; } ); $requires = isset( $entry['requires'] ) && '' !== $entry['requires'] ? (string) $entry['requires'] : null; $normalized[ $slug ] = array( 'label' => $label, 'description' => isset( $entry['description'] ) ? (string) $entry['description'] : '', 'tools' => array_values( array_unique( $tools ) ), 'requires' => $requires, 'is_default' => ! empty( $entry['is_default'] ), ); } // The floor. A callback that dropped it gets it back. if ( ! isset( $normalized[ self::LEGACY ] ) ) { $seed = self::seed(); $normalized[ self::LEGACY ] = array( 'label' => (string) $seed[ self::LEGACY ]['label'], 'description' => (string) $seed[ self::LEGACY ]['description'], 'tools' => array_values( (array) $seed[ self::LEGACY ]['tools'] ), 'requires' => null, 'is_default' => false, ); } return $normalized; } /** * Whether a plugin FOLDER slug is installed AND active. * * THE single implementation of "is this required plugin present and running" * (B32). `is_available()` calls it for a type's `requires`, and * `AbilitiesManagerPromoCard` calls it for the sibling add-on, so the gate * and the notice can never disagree about the same site. * * Both "not installed" and "installed but deactivated" count as unmet; they * differ only in the wording of the remedy the admin surfaces offer, which * is why only the admin card distinguishes them. * * **Matches by DIRECTORY, never by filename.** `requires` is documented as a * plugin FOLDER slug, and WordPress stores active plugins as `folder/file.php` * where the file is very often NOT named after the folder: * `advanced-custom-fields/acf.php`, `wordpress-seo/wp-seo.php`, * `sfwd-lms/sfwd_lms.php`, `all-in-one-seo-pack/all_in_one_seo_pack.php`, * `wpforms-lite/wpforms.php`. An earlier version assumed `slug/slug.php`, * which held only for the two types this plugin ships — every third-party * type declaring a real-world `requires` resolved as permanently unavailable: * never selectable, never enablable, and at runtime `SetupRequired` told the * operator to install a plugin that was already installed and active. * * The `slug . '/'` prefix (rather than a bare substring) is what stops * `acf` matching `acf-pro`. * * Reads the stored option directly rather than calling `is_plugin_active()`, * which lives in `wp-admin/includes/plugin.php`. This class is * context-neutral (A3) and runs on every MCP and REST request; pulling the * admin bootstrap into the transport path to answer a question the options * table already holds is the wrong trade. * * @since 0.1.0 * @param string $plugin_slug Plugin folder slug. * @return bool */ public static function plugin_is_active( string $plugin_slug ): bool { if ( '' === $plugin_slug ) { return false; } $prefix = $plugin_slug . '/'; foreach ( (array) get_option( 'active_plugins', array() ) as $plugin_file ) { if ( 0 === strpos( (string) $plugin_file, $prefix ) ) { return true; } } // Network-activated plugins are stored separately, keyed by plugin file. // F090 is scoped single-site, but a network-activated sibling is a real // configuration and reporting it as missing would strand the operator on // a site where the dependency is genuinely satisfied. if ( is_multisite() ) { $sitewide = (array) get_site_option( 'active_sitewide_plugins', array() ); foreach ( array_keys( $sitewide ) as $plugin_file ) { if ( 0 === strpos( (string) $plugin_file, $prefix ) ) { return true; } } } return false; } }