=' ) && version_compare( $loaded_version, AAFM_MAX_ADAPTER_VERSION, '<' ); } /** * Whether the loaded adapter is newer than the tested upper bound. * * Lets aafm_init_mcp() pick the "too new" notice apart from the "too old" one. * * @param string $loaded_version Version reported by the active adapter copy. * @return bool */ function aafm_adapter_is_too_new( string $loaded_version ): bool { return version_compare( $loaded_version, AAFM_MAX_ADAPTER_VERSION, '>=' ); } /** * The version of the adapter actually loaded - whichever copy our eager load committed PHP to, or * a sibling's copy that was declared before us. * * @return string|null */ function aafm_loaded_adapter_version(): ?string { if ( ! class_exists( McpAdapter::class ) ) { return null; } return defined( McpAdapter::class . '::VERSION' ) ? (string) McpAdapter::VERSION : '0.0.0'; } /** * Initialize the MCP layer if a compatible adapter is present; otherwise show a notice. * * @return bool True when initialization proceeded. */ function aafm_init_mcp(): bool { $version = aafm_loaded_adapter_version(); if ( null === $version ) { add_action( 'admin_notices', 'aafm_notice_adapter_missing' ); return false; } if ( ! aafm_adapter_is_compatible( $version ) ) { if ( aafm_adapter_is_too_new( $version ) ) { add_action( 'admin_notices', 'aafm_notice_adapter_too_new' ); } else { add_action( 'admin_notices', 'aafm_notice_adapter_outdated' ); } return false; } // Only our governed server should exist. add_filter( 'mcp_adapter_create_default_server', '__return_false' ); McpAdapter::instance(); add_action( 'mcp_adapter_init', 'aafm_register_mcp_server' ); return true; } /** * Admin notice: no adapter available. * * @return void */ function aafm_notice_adapter_missing(): void { if ( ! current_user_can( 'activate_plugins' ) ) { return; } echo '

'; echo esc_html__( 'Agent Abilities for MCP could not load the MCP adapter. Please reinstall the plugin.', 'agent-abilities-for-mcp' ); echo '

'; } /** * Admin notice: another plugin loaded an adapter older than our floor. * * Names the offending plugin when it can be resolved (reflect the loaded McpAdapter class file, * map it to its plugin folder under WP_PLUGIN_DIR, read its header name), and reports the loaded * vs required versions, so the operator knows exactly which plugin to update or deactivate. Falls * back to the generic wording when the plugin cannot be resolved. All output is escaped. * * @return void */ function aafm_notice_adapter_outdated(): void { aafm_render_adapter_version_notice( /* translators: 1: offending plugin name, 2: loaded adapter version, 3: minimum required adapter version. */ esc_html__( 'Agent Abilities for MCP is disabled: the plugin %1$s is loading MCP Adapter %2$s, but Agent Abilities for MCP requires %3$s or newer. Update or deactivate %1$s to enable agent tools.', 'agent-abilities-for-mcp' ), /* translators: 1: loaded adapter version, 2: minimum required adapter version. */ esc_html__( 'Agent Abilities for MCP is disabled: another active plugin is loading MCP Adapter %1$s, but %2$s or newer is required. Update or deactivate that plugin to enable agent tools.', 'agent-abilities-for-mcp' ), AAFM_MIN_ADAPTER_VERSION ); } /** * Admin notice: another plugin loaded an adapter NEWER than our tested upper bound. * * A 0.7+ adapter may have changed the create_server() signature or response shape the plugin is * built against, so it is disabled rather than risking a runtime break. Names the offending plugin * when it can be resolved, and reports the loaded vs maximum-supported versions. All output escaped. * * @return void */ function aafm_notice_adapter_too_new(): void { aafm_render_adapter_version_notice( /* translators: 1: offending plugin name, 2: loaded adapter version, 3: maximum supported adapter version (exclusive). */ esc_html__( 'Agent Abilities for MCP is disabled: the plugin %1$s is loading MCP Adapter %2$s, which is newer than this plugin supports (below %3$s). Update Agent Abilities for MCP, or deactivate %1$s, to enable agent tools.', 'agent-abilities-for-mcp' ), /* translators: 1: loaded adapter version, 2: maximum supported adapter version (exclusive). */ esc_html__( 'Agent Abilities for MCP is disabled: another active plugin is loading MCP Adapter %1$s, which is newer than this plugin supports (below %2$s). Update Agent Abilities for MCP, or deactivate that plugin, to enable agent tools.', 'agent-abilities-for-mcp' ), AAFM_MAX_ADAPTER_VERSION ); } /** * Shared renderer for the two adapter-version compatibility notices. Only the wording and which * version bound gets cited differ between "too old" and "too new"; the capability gate, the * loaded-version/offending-plugin resolution, and the markup are identical either way. * * @param string $with_plugin_message Pre-escaped printf() template used when the offending * plugin is known, taking %1$s (plugin), %2$s (loaded * version), %3$s (the version bound). * @param string $without_plugin_message Pre-escaped printf() template used when the offending * plugin cannot be resolved, taking %1$s (loaded version), * %2$s (the version bound). * @param string $bound_version The adapter version bound this message cites * (AAFM_MIN_ADAPTER_VERSION or AAFM_MAX_ADAPTER_VERSION). * @return void */ function aafm_render_adapter_version_notice( string $with_plugin_message, string $without_plugin_message, string $bound_version ): void { if ( ! current_user_can( 'activate_plugins' ) ) { return; } $loaded = aafm_loaded_adapter_version() ?? __( 'unknown', 'agent-abilities-for-mcp' ); $plugin = aafm_resolve_adapter_owner_plugin(); echo '

'; if ( '' !== $plugin ) { printf( $with_plugin_message, esc_html( $plugin ), esc_html( $loaded ), esc_html( $bound_version ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $with_plugin_message is the caller's own esc_html__() return value; every interpolated arg is escaped here. } else { printf( $without_plugin_message, esc_html( $loaded ), esc_html( $bound_version ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $without_plugin_message is the caller's own esc_html__() return value; every interpolated arg is escaped here. } echo '

'; } /** * Resolve the display name of the plugin whose copy of the MCP adapter won the autoload. * * Reflects the loaded WP\MCP\Core\McpAdapter class file path, finds which plugin folder under * WP_PLUGIN_DIR contains it, then reads that plugin's header name via get_plugins(). Returns an * empty string when the class is absent, the file is outside the plugins directory, or no plugin * header can be matched - the caller then uses the generic wording. * * @return string Plugin display name, or '' when it cannot be resolved. */ function aafm_resolve_adapter_owner_plugin(): string { if ( ! class_exists( McpAdapter::class ) ) { return ''; } try { $file = ( new ReflectionClass( McpAdapter::class ) )->getFileName(); } catch ( \ReflectionException $e ) { return ''; } if ( ! is_string( $file ) || '' === $file ) { return ''; } $plugins_dir = wp_normalize_path( WP_PLUGIN_DIR ); $class_file = wp_normalize_path( $file ); // The class must live under the plugins directory for us to name a plugin. if ( 0 !== strpos( $class_file, trailingslashit( $plugins_dir ) ) ) { return ''; } // The first path segment after the plugins dir is the owning plugin's folder. $relative = ltrim( substr( $class_file, strlen( $plugins_dir ) ), '/' ); $segments = explode( '/', $relative ); $folder = $segments[0] ?? ''; if ( '' === $folder ) { return ''; } if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all_plugins = get_plugins(); foreach ( $all_plugins as $plugin_file => $data ) { // $plugin_file is "folder/entry.php"; match on the folder segment. if ( 0 === strpos( $plugin_file, $folder . '/' ) && ! empty( $data['Name'] ) ) { return (string) $data['Name']; } } return ''; } /** * Bound the initialize params before the MCP adapter turns them into a session row. * * SessionManager::create_session() writes the entire params array into the calling user's * `mcp_adapter_sessions` meta, verbatim and unvalidated, up to the per-user session cap. A * subscriber - sixteen read-only tools, no write ability anywhere - sent a two-million-character * clientInfo.name, repeated it to the cap, and grew their own usermeta row from 288 bytes to 64 MB. * Every call answered 200, and their next ordinary handshake took nearly four times as long, * because that row is unserialised on every request that primes their meta (B2-12). * * The storage belongs to the vendored adapter, but the gate belongs here: this plugin is the * governance layer in front of it, and "the library wrote it" is not a defence a governance layer * gets to use. Bounding at the request is also the only place the fix can live without patching * vendor code that composer will replace. * * Two different treatments, deliberately: * * - clientInfo is REWRITTEN. It is descriptive metadata, the protocol defines exactly two fields * for it, and truncating an absurd name still leaves a usable label. Unknown fields are dropped * rather than shortened, since nothing downstream reads them. The name goes through the same * plain-text helper every other stored, operator-visible name does. * - Everything else is REFUSED when the whole params object is oversized. Trimming an unknown key * risks silently mangling a capabilities negotiation, and a client that sends 64 KB of handshake * deserves a clear error rather than a quiet, partial success. * * @param mixed $result Existing short-circuit result, or null. * @param mixed $server The REST server (unused). * @param mixed $request The request being dispatched. * @return mixed A 400 WP_Error for an oversized params object, otherwise $result unchanged. */ function aafm_bound_mcp_initialize_params( $result, $server = null, $request = null ) { unset( $server ); if ( null !== $result || ! $request instanceof \WP_REST_Request || 'POST' !== $request->get_method() ) { return $result; } // Case-insensitive, matching core's own route matching and the sibling checks in server.php. if ( 0 !== strcasecmp( rtrim( (string) $request->get_route(), '/' ), rtrim( aafm_mcp_rest_route(), '/' ) ) ) { return $result; } $body = $request->get_json_params(); if ( ! is_array( $body ) || array() === $body ) { return $result; } // A JSON-RPC batch is a list of call objects. Handling only the single-object form let the same // oversized handshake through simply by wrapping it in brackets (R6-3), so both shapes walk the // same path and every element is bounded on its own. $is_batch = array_keys( $body ) === range( 0, count( $body ) - 1 ); $calls = $is_batch ? $body : array( $body ); $changed = false; foreach ( $calls as $index => $call ) { if ( ! is_array( $call ) || 'initialize' !== ( $call['method'] ?? '' ) || ! isset( $call['params'] ) || ! is_array( $call['params'] ) ) { continue; } $bounded = aafm_bound_initialize_params_array( $call['params'] ); if ( null === $bounded ) { return new WP_Error( 'aafm_initialize_params_too_large', __( 'The initialize parameters are too large. Send a normal handshake: a protocol version, your capabilities, and a short client name and version.', 'agent-abilities-for-mcp' ), array( 'status' => 400 ) ); } if ( $bounded !== $call['params'] ) { $calls[ $index ]['params'] = $bounded; $changed = true; } } if ( $changed ) { // set_body() clears the parsed-JSON cache, so the adapter reads the bounded version. $request->set_body( (string) wp_json_encode( $is_batch ? $calls : $calls[0] ) ); } return $result; } /** * Bound one initialize params object, or report that it is too large to accept at all. * * @param array $params Params from a single initialize call. * @return array|null The bounded params, or null when the object exceeds the cap. */ function aafm_bound_initialize_params_array( array $params ): ?array { if ( isset( $params['clientInfo'] ) && is_array( $params['clientInfo'] ) ) { $client = array(); foreach ( array( 'name', 'version' ) as $field ) { if ( ! isset( $params['clientInfo'][ $field ] ) || ! is_scalar( $params['clientInfo'][ $field ] ) ) { continue; } $value = aafm_sanitize_plain_text( (string) $params['clientInfo'][ $field ] ); if ( strlen( $value ) > AAFM_MCP_CLIENT_FIELD_MAX ) { $value = substr( $value, 0, AAFM_MCP_CLIENT_FIELD_MAX ); } $client[ $field ] = $value; } $params['clientInfo'] = $client; } if ( strlen( (string) wp_json_encode( $params ) ) > AAFM_MCP_INITIALIZE_PARAMS_MAX ) { return null; } return $params; } add_filter( 'rest_pre_dispatch', 'aafm_bound_mcp_initialize_params', 10, 3 );