diff --git a/docs/experiments/webmcp-adapter.md b/docs/experiments/webmcp-adapter.md new file mode 100644 index 000000000..e32fc9c5b --- /dev/null +++ b/docs/experiments/webmcp-adapter.md @@ -0,0 +1,112 @@ +# WebMCP Adapter + +## Summary + +The WebMCP Adapter experiment exposes WordPress abilities to browser agents through `navigator.modelContext` using a layered tool surface: + +- `wp-discover-abilities` +- `wp-get-ability-info` +- `wp-execute-ability` + +It runs in wp-admin and executes abilities through the Abilities client API (`@wordpress/abilities` or `wp.abilities`), so server-side permission callbacks and REST auth still remain authoritative. + +## Exposure Model + +An ability is exposed to agents only when: + +1. `meta.mcp.public === true` +2. Optional `meta.mcp.context` rules match the current WordPress page context. + +Supported context rules: + +- `screens`: matches `pagenow` (for example `post.php`, `site-editor.php`) +- `adminPages`: matches `adminpage` +- `postTypes`: matches `typenow` (or `post_type` query var fallback) +- `query`: exact query-var matching + +## Confirmation Policy + +Execution confirmation uses ability annotations: + +- `meta.annotations.readonly === true` -> no extra confirmation +- `meta.annotations.destructive === true` -> double confirmation +- otherwise -> standard confirmation prompt + +Prompts are requested through `agent.requestUserInteraction()` when available. + +## Debug Panel + +The experiment includes an optional in-page debug panel to verify WebMCP registration and run quick tool calls. + +Enablement options: + +- **Experiment setting:** `Enable WebMCP debug panel` in `Settings -> AI Experiments` +- **Runtime JS flag:** `window.aiWebMCPDebug = true` (or `false` to disable) + +Advanced runtime config: + +```js +window.aiWebMCPDebug = { + enabled: true, + open: true, + shimModelContext: true, // Optional: auto-install local modelContext shim for browser testing +}; +``` + +The adapter also exposes a small runtime API: + +```js +window.aiWebMCPAdapterDebug.enable(); +window.aiWebMCPAdapterDebug.disable(); +window.aiWebMCPAdapterDebug.refresh(); +window.aiWebMCPAdapterDebug.getState(); +window.aiWebMCPAdapterDebug.register( { forceReloadAbilities: true } ); +window.aiWebMCPAdapterDebug.installModelContextShim(); +window.aiWebMCPAdapterDebug.callTool( 'discover' ); +``` + +## Hooks + +### `ai_webmcp_adapter_allowed_hooks` + +Filters admin hooks where the adapter is enqueued. + +Default hooks: + +- `post.php` +- `post-new.php` +- `site-editor.php` +- `appearance_page_gutenberg-edit-site` +- `admin_page_gutenberg-edit-site` +- `appearance_page_site-editor-v2` + +### `ai_webmcp_adapter_tool_names` + +Filters layered tool names. + +Default: + +- `discover`: `wp-discover-abilities` +- `info`: `wp-get-ability-info` +- `execute`: `wp-execute-ability` + +### `ai.webmcp.isAbilityExposed` (JS filter) + +Client-side filter (via `@wordpress/hooks`) to override default exposure decisions. + +Arguments: + +1. `isExposed` (`boolean`) +2. `ability` (`Ability`) +3. `wpContext` (`{ screen, adminPage, postType, query }`) + +## Requirements + +- Browser must support `navigator.modelContext` to register tools. +- The Gutenberg plugin must be active (WebMCP availability is gated on Gutenberg because it provides the Abilities API integration this experiment relies on). +- WordPress abilities API must be available, either via: + - `window.wp.abilities` globals, or + - script modules (`@wordpress/core-abilities` + `@wordpress/abilities`). +- If dependencies are unavailable, the WebMCP toggle is disabled in settings and forced off server-side on save. + +For browsers without native WebMCP support, you can enable the local debug shim with `window.aiWebMCPDebug = { enabled: true, shimModelContext: true }` or `window.aiWebMCPAdapterDebug.installModelContextShim()`. diff --git a/includes/Abstracts/Abstract_Experiment.php b/includes/Abstracts/Abstract_Experiment.php index 8bf0b5e33..7c0c900c9 100644 --- a/includes/Abstracts/Abstract_Experiment.php +++ b/includes/Abstracts/Abstract_Experiment.php @@ -132,6 +132,33 @@ public function get_description(): string { return $this->description; } + /** + * Checks if experiment can currently be enabled. + * + * Child classes can override this to enforce runtime dependencies + * (for example required plugins or APIs). + * + * @since 0.4.0 + * + * @return bool True when available, false otherwise. + */ + public function is_available(): bool { + return true; + } + + /** + * Gets a human-readable reason for why the experiment is unavailable. + * + * Child classes can override this to provide contextual guidance in UI. + * + * @since 0.4.0 + * + * @return string Availability message or an empty string when not provided. + */ + public function get_unavailable_reason(): string { + return ''; + } + /** * Checks if experiment is enabled. * @@ -148,6 +175,12 @@ final public function is_enabled(): bool { return $this->enabled_cache; } + // Experiments cannot be enabled when runtime dependencies are unavailable. + if ( ! $this->is_available() ) { + $this->enabled_cache = false; + return false; + } + // Check global experiments toggle first. $global_enabled = (bool) get_option( Settings_Registration::GLOBAL_OPTION, false ); if ( ! $global_enabled ) { diff --git a/includes/Experiment_Loader.php b/includes/Experiment_Loader.php index f5af46407..6a70e76f0 100644 --- a/includes/Experiment_Loader.php +++ b/includes/Experiment_Loader.php @@ -110,6 +110,7 @@ private function get_default_experiments(): array { \WordPress\AI\Experiments\Image_Generation\Image_Generation::class, \WordPress\AI\Experiments\Summarization\Summarization::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, + \WordPress\AI\Experiments\WebMCP\WebMCP::class, ); /** diff --git a/includes/Experiments/WebMCP/WebMCP.php b/includes/Experiments/WebMCP/WebMCP.php new file mode 100644 index 000000000..a87b20752 --- /dev/null +++ b/includes/Experiments/WebMCP/WebMCP.php @@ -0,0 +1,493 @@ + 'webmcp-adapter', + 'label' => __( 'WebMCP Adapter', 'ai' ), + 'description' => __( 'Exposes abilities to in-browser agents via navigator.modelContext with WordPress context-aware filtering.', 'ai' ), + ); + } + + /** + * {@inheritDoc} + * + * @since 0.4.0 + */ + public function register(): void { + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Enqueues and localizes WebMCP adapter assets. + * + * @since 0.4.0 + * + * @param string $hook_suffix Current admin page hook suffix. + */ + public function enqueue_assets( string $hook_suffix ): void { + if ( ! $this->should_enqueue_for_hook( $hook_suffix ) ) { + return; + } + + $this->enqueue_script_modules(); + + Asset_Loader::enqueue_script( + self::SCRIPT_HANDLE, + 'experiments/webmcp-adapter', + array( + 'dependencies' => $this->get_script_dependencies(), + 'version' => AI_EXPERIMENTS_VERSION, + ) + ); + + Asset_Loader::localize_script( + self::SCRIPT_HANDLE, + 'WebMCPAdapterData', + array( + 'toolNames' => $this->get_tool_names(), + 'wpContext' => $this->get_wp_context(), + 'debugPanelEnabled' => $this->is_debug_panel_enabled(), + ) + ); + } + + /** + * Registers custom WebMCP settings. + * + * @since 0.4.0 + */ + public function register_settings(): void { + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_debug_panel_option_name(), + array( + 'type' => 'boolean', + 'default' => false, + 'sanitize_callback' => 'rest_sanitize_boolean', + ) + ); + + add_filter( + "sanitize_option_{$this->get_enabled_option_name()}", + array( $this, 'sanitize_enabled_setting' ), + 10, + 3 + ); + } + + /** + * Renders custom WebMCP settings fields. + * + * @since 0.4.0 + */ + public function render_settings_fields(): void { + $option_name = $this->get_debug_panel_option_name(); + $option_id = $option_name; + $is_enabled = $this->is_debug_panel_enabled(); + $desc_id = "ai-experiment-{$this->get_id()}-debug-panel-desc"; + ?> +
+ +

+ +

+
+ has_abilities_api_support(); + + /** + * Filters whether the WebMCP Adapter is available for enablement. + * + * @since 0.4.0 + * + * @param bool $is_available True when dependencies are available. + */ + return (bool) apply_filters( 'ai_webmcp_adapter_is_available', $is_available ); + } + + /** + * {@inheritDoc} + * + * @since 0.4.0 + */ + public function get_unavailable_reason(): string { + if ( $this->is_available() ) { + return ''; + } + + return __( 'Requires the Gutenberg plugin (which provides the WordPress Abilities API). Install and activate Gutenberg to enable this experiment.', 'ai' ); + } + + /** + * Sanitizes the experiment enabled toggle. + * + * Prevents enabling the experiment when required dependencies are missing. + * + * @since 0.4.0 + * + * @param mixed $value Sanitized option value. + * @param string $option Option name. + * @param mixed $original_value Original submitted value. + * @return bool True when enabled value is allowed, false otherwise. + */ + public function sanitize_enabled_setting( $value, string $option, $original_value ): bool { + if ( is_bool( $value ) ) { + $enabled = $value; + } elseif ( is_scalar( $value ) ) { + $enabled = rest_sanitize_boolean( (string) $value ); + } else { + $enabled = false; + } + + if ( ! $enabled || $this->is_available() ) { + return $enabled; + } + + add_settings_error( + Settings_Registration::OPTION_GROUP, + "{$option}_unavailable", + $this->get_unavailable_reason(), + 'error' + ); + + return false; + } + + /** + * Returns whether the adapter should load on the current admin hook. + * + * @since 0.4.0 + * + * @param string $hook_suffix Current admin hook suffix. + * @return bool True when the hook should load the adapter. + */ + private function should_enqueue_for_hook( string $hook_suffix ): bool { + $default_hooks = array( + 'post.php', + 'post-new.php', + 'site-editor.php', + 'appearance_page_gutenberg-edit-site', + 'admin_page_gutenberg-edit-site', + 'appearance_page_site-editor-v2', + ); + + /** + * Filters admin hooks where the WebMCP adapter is enqueued. + * + * @since 0.4.0 + * + * @param array $allowed_hooks List of allowed hook suffixes. + * @param string $hook_suffix Current admin hook suffix. + */ + $allowed_hooks = apply_filters( 'ai_webmcp_adapter_allowed_hooks', $default_hooks, $hook_suffix ); + + return in_array( $hook_suffix, $allowed_hooks, true ); + } + + /** + * Returns script dependencies that are currently registered. + * + * @since 0.4.0 + * + * @return array Script dependency handles. + */ + private function get_script_dependencies(): array { + $candidate_handles = array( + 'wp-hooks', + 'wp-abilities', + 'wp-core-abilities', + ); + + $dependencies = array(); + + foreach ( $candidate_handles as $handle ) { + if ( ! wp_script_is( $handle, 'registered' ) ) { + continue; + } + + $dependencies[] = $handle; + } + + return $dependencies; + } + + /** + * Enqueues script modules used to initialize and expose abilities in modern Gutenberg environments. + * + * @since 0.4.0 + */ + private function enqueue_script_modules(): void { + if ( ! function_exists( 'wp_enqueue_script_module' ) || ! function_exists( 'wp_script_modules' ) ) { + return; + } + + $script_modules = wp_script_modules(); + if ( ! method_exists( $script_modules, 'is_registered' ) ) { + return; + } + + $candidate_module_ids = array( + '@wordpress/core-abilities', + '@wordpress/abilities', + ); + + foreach ( $candidate_module_ids as $module_id ) { + if ( ! $script_modules->is_registered( $module_id ) ) { + continue; + } + + wp_enqueue_script_module( $module_id ); + } + } + + /** + * Returns WebMCP tool names, filterable by developers. + * + * @since 0.4.0 + * + * @return array{discover: string, info: string, execute: string} Tool names. + */ + private function get_tool_names(): array { + $tool_names = array( + 'discover' => 'wp-discover-abilities', + 'info' => 'wp-get-ability-info', + 'execute' => 'wp-execute-ability', + ); + + /** + * Filters WebMCP layered tool names. + * + * @since 0.4.0 + * + * @param array $tool_names Tool name map. + */ + $tool_names = apply_filters( 'ai_webmcp_adapter_tool_names', $tool_names ); + + $defaults = array( + 'discover' => 'wp-discover-abilities', + 'info' => 'wp-get-ability-info', + 'execute' => 'wp-execute-ability', + ); + + foreach ( $defaults as $key => $default_value ) { + $candidate = $tool_names[ $key ] ?? ''; + $defaults[ $key ] = is_string( $candidate ) && '' !== $candidate ? $candidate : $default_value; + } + + return $defaults; + } + + /** + * Returns current WordPress context for WebMCP filtering. + * + * @since 0.4.0 + * + * @return array{ + * screen: string, + * adminPage: string, + * postType: string, + * query: array + * } Context payload. + */ + private function get_wp_context(): array { + global $admin_page; + global $pagenow; + + $screen = get_current_screen(); + $raw_query_vars = wp_unslash( + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only context hinting for client-side filtering. + $_GET + ); + $query_vars = is_array( $raw_query_vars ) ? $raw_query_vars : array(); + + return array( + 'screen' => is_string( $pagenow ) ? $pagenow : '', + 'adminPage' => is_string( $admin_page ) ? $admin_page : '', + 'postType' => isset( $screen->post_type ) && is_string( $screen->post_type ) ? $screen->post_type : '', + 'query' => $this->sanitize_query_vars( $query_vars ), + ); + } + + /** + * Sanitizes query vars for safe client-side context usage. + * + * @since 0.4.0 + * + * @param array $query_vars Raw query vars. + * @return array Sanitized query vars. + */ + private function sanitize_query_vars( array $query_vars ): array { + $sanitized = array(); + + foreach ( $query_vars as $key => $value ) { + if ( ! is_string( $key ) ) { + continue; + } + + if ( ! is_scalar( $value ) ) { + continue; + } + + $sanitized_key = sanitize_key( $key ); + + if ( '' === $sanitized_key ) { + continue; + } + + $sanitized[ $sanitized_key ] = sanitize_text_field( (string) $value ); + } + + return $sanitized; + } + + /** + * Returns the debug panel option name. + * + * @since 0.4.0 + * + * @return string Debug panel option name. + */ + private function get_debug_panel_option_name(): string { + return $this->get_field_option_name( self::DEBUG_PANEL_OPTION_KEY ); + } + + /** + * Returns the experiment enabled option name. + * + * @since 0.4.0 + * + * @return string Enabled option name. + */ + private function get_enabled_option_name(): string { + return "ai_experiment_{$this->get_id()}_enabled"; + } + + /** + * Returns whether the debug panel is enabled by settings. + * + * @since 0.4.0 + * + * @return bool True when enabled. + */ + private function is_debug_panel_enabled(): bool { + return (bool) get_option( $this->get_debug_panel_option_name(), false ); + } + + /** + * Checks whether required Abilities API functions are available. + * + * @since 0.4.0 + * + * @return bool True when WebMCP can rely on the Abilities API. + */ + private function has_abilities_api_support(): bool { + return $this->is_gutenberg_active() + && function_exists( 'wp_get_abilities' ) + && function_exists( 'wp_register_ability' ); + } + + /** + * Checks whether Gutenberg is currently active. + * + * Uses lightweight checks only, avoiding loading admin-only plugin APIs. + * + * @since 0.4.0 + * + * @return bool True when Gutenberg is active. + */ + private function is_gutenberg_active(): bool { + if ( defined( 'GUTENBERG_VERSION' ) ) { + return true; + } + + $plugin_file = 'gutenberg/gutenberg.php'; + $active_plugins = get_option( 'active_plugins', array() ); + + if ( is_array( $active_plugins ) && in_array( $plugin_file, $active_plugins, true ) ) { + return true; + } + + if ( ! is_multisite() ) { + return false; + } + + $network_active_plugins = get_site_option( 'active_sitewide_plugins', array() ); + + return is_array( $network_active_plugins ) && isset( $network_active_plugins[ $plugin_file ] ); + } +} diff --git a/includes/Settings/Settings_Page.php b/includes/Settings/Settings_Page.php index 3ef227fd2..21ebe0091 100644 --- a/includes/Settings/Settings_Page.php +++ b/includes/Settings/Settings_Page.php @@ -204,64 +204,79 @@ class="button -
    - registry->get_all_experiments() as $experiment ) : ?> - get_id(); - $experiment_option = "ai_experiment_{$experiment_id}_enabled"; - $experiment_enabled = (bool) get_option( $experiment_option, false ); - $disabled_class = ! $global_enabled ? 'ai-experiments__item--disabled' : ''; - $desc_id = "ai-experiment-{$experiment_id}-desc"; - ?> -
  • -
    - -
    - get_description() ) : ?> -

    - get_description(), - array( - 'a' => array( - 'href' => array(), - 'title' => array(), - 'target' => array(), - 'rel' => array(), - ), - 'b' => array(), - 'strong' => array(), - 'em' => array(), - 'i' => array(), - ) - ); - ?> -

    - +
      + registry->get_all_experiments() as $experiment ) : ?> render_settings_fields(); + $experiment_id = $experiment->get_id(); + $experiment_option = "ai_experiment_{$experiment_id}_enabled"; + $experiment_enabled = (bool) get_option( $experiment_option, false ); + $experiment_available = method_exists( $experiment, 'is_available' ) ? (bool) $experiment->is_available() : true; + $experiment_unavailable_reason = ''; + + if ( ! $experiment_available && method_exists( $experiment, 'get_unavailable_reason' ) ) { + $reason = $experiment->get_unavailable_reason(); + $experiment_unavailable_reason = is_string( $reason ) ? $reason : ''; } + + $is_toggle_disabled = ! $global_enabled || ! $experiment_available; + $is_toggle_checked = $experiment_enabled && $experiment_available; + $disabled_class = $is_toggle_disabled ? 'ai-experiments__item--disabled' : ''; + $desc_id = "ai-experiment-{$experiment_id}-desc"; ?> - - -
    +
  • +
    + +
    + get_description() ) : ?> +

    + get_description(), + array( + 'a' => array( + 'href' => array(), + 'title' => array(), + 'target' => array(), + 'rel' => array(), + ), + 'b' => array(), + 'strong' => array(), + 'em' => array(), + 'i' => array(), + ) + ); + ?> +

    + + +

    + +

    + + render_settings_fields(); + } + ?> +
  • + +
diff --git a/src/experiments/webmcp-adapter/index.js b/src/experiments/webmcp-adapter/index.js new file mode 100644 index 000000000..5349628a2 --- /dev/null +++ b/src/experiments/webmcp-adapter/index.js @@ -0,0 +1,1266 @@ +/** + * WebMCP adapter for WordPress abilities. + */ + +( function () { + 'use strict'; + + const adapterData = window.aiWebMCPAdapterData || {}; + + const DEFAULT_TOOL_NAMES = { + discover: 'wp-discover-abilities', + info: 'wp-get-ability-info', + execute: 'wp-execute-ability', + }; + + const debugState = { + enabled: false, + open: true, + modelContextAvailable: false, + modelContextSource: '', + modelContextShimInstalled: false, + abilitiesApiAvailable: false, + abilitiesApiSource: '', + abilitiesApiError: '', + registrationSucceeded: false, + registrationError: '', + registrationAttempts: 0, + toolNames: [], + context: null, + }; + + let registeredTools = []; + let debugPanelElements = null; + let modelContextShim = null; + let abilitiesApiPromise = null; + + /** + * Returns a plain object when valid, otherwise an empty object. + * + * @param {unknown} value Candidate value. + * @return {Object} Safe object. + */ + function asObject( value ) { + if ( value && typeof value === 'object' && ! Array.isArray( value ) ) { + return value; + } + return {}; + } + + /** + * Returns a normalized string array. + * + * @param {unknown} value Candidate value. + * @return {Array} String array. + */ + function normalizeStringArray( value ) { + if ( typeof value === 'string' && value ) { + return [ value ]; + } + + if ( Array.isArray( value ) ) { + return value.filter( + ( item ) => typeof item === 'string' && Boolean( item ) + ); + } + + return []; + } + + /** + * Converts unknown errors into a stable string. + * + * @param {unknown} error Candidate error. + * @return {string} Error message. + */ + function getErrorMessage( error ) { + if ( error instanceof Error && error.message ) { + return error.message; + } + + return String( error ); + } + + /** + * Safely serializes a value for debug output. + * + * @param {unknown} value Value to serialize. + * @return {string} Serialized text. + */ + function toDebugText( value ) { + if ( typeof value === 'string' ) { + return value; + } + + try { + return JSON.stringify( value, null, 2 ); + } catch { + return String( value ); + } + } + + /** + * Converts any value to WebMCP text content. + * + * @param {unknown} value Result payload. + * @return {{ content: Array<{ type: string, text: string }> }} Text result. + */ + function toTextContentResult( value ) { + return { + content: [ { type: 'text', text: toDebugText( value ) } ], + }; + } + + /** + * Returns query vars from location. + * + * @return {Object} Query vars. + */ + function getQueryVarsFromLocation() { + const query = {}; + const search = window?.location?.search; + + if ( typeof search !== 'string' || ! search ) { + return query; + } + + const params = new URLSearchParams( search ); + params.forEach( ( value, key ) => { + if ( typeof value === 'string' ) { + query[ key ] = value; + } + } ); + + return query; + } + + /** + * Returns current WordPress context for filtering. + * + * @return {{screen?: string, adminPage?: string, postType?: string, query: Object}} Context. + */ + function getWordPressWebMcpContext() { + const localizedContext = asObject( adapterData.wpContext ); + const localizedQuery = asObject( localizedContext.query ); + const query = { + ...localizedQuery, + ...getQueryVarsFromLocation(), + }; + + const queryPostType = + query.postType || query.post_type || query.post_type_name; + + return { + screen: + typeof window.pagenow === 'string' + ? window.pagenow + : localizedContext.screen, + adminPage: + typeof window.adminpage === 'string' + ? window.adminpage + : localizedContext.adminPage, + postType: + typeof window.typenow === 'string' + ? window.typenow + : queryPostType || localizedContext.postType, + query, + }; + } + + /** + * Checks if ability context rules match current WP context. + * + * @param {Object | undefined} ability Ability object. + * @param {{screen?: string, adminPage?: string, postType?: string, query: Object}} wpContext Current context. + * @return {boolean} True if context matches. + */ + function doesAbilityMatchWpContext( ability, wpContext ) { + const contextRules = asObject( ability?.meta?.mcp?.context ); + + const allowedScreens = normalizeStringArray( contextRules.screens ); + if ( + allowedScreens.length > 0 && + ( typeof wpContext.screen !== 'string' || + ! allowedScreens.includes( wpContext.screen ) ) + ) { + return false; + } + + const allowedAdminPages = normalizeStringArray( + contextRules.adminPages + ); + if ( + allowedAdminPages.length > 0 && + ( typeof wpContext.adminPage !== 'string' || + ! allowedAdminPages.includes( wpContext.adminPage ) ) + ) { + return false; + } + + const allowedPostTypes = normalizeStringArray( contextRules.postTypes ); + if ( + allowedPostTypes.length > 0 && + ( typeof wpContext.postType !== 'string' || + ! allowedPostTypes.includes( wpContext.postType ) ) + ) { + return false; + } + + const queryRules = asObject( contextRules.query ); + const queryKeys = Object.keys( queryRules ); + + for ( const key of queryKeys ) { + const allowedValues = normalizeStringArray( queryRules[ key ] ); + if ( allowedValues.length === 0 ) { + continue; + } + + if ( + typeof wpContext.query?.[ key ] !== 'string' || + ! allowedValues.includes( wpContext.query[ key ] ) + ) { + return false; + } + } + + return true; + } + + /** + * Returns whether ability is marked public for agents. + * + * @param {Object | undefined} ability Ability object. + * @return {boolean} True when public. + */ + function isAbilityPublicForAgents( ability ) { + return Boolean( ability?.meta?.mcp?.public ); + } + + /** + * Returns confirmation level for ability execution. + * + * @param {Object | undefined} ability Ability object. + * @return {'none'|'default'|'destructive'} Confirmation level. + */ + function getAbilityConfirmationLevel( ability ) { + if ( ability?.meta?.annotations?.readonly ) { + return 'none'; + } + + if ( ability?.meta?.annotations?.destructive ) { + return 'destructive'; + } + + return 'default'; + } + + /** + * Applies a filter to ability exposure when hooks are available. + * + * @param {boolean} defaultValue Default exposure decision. + * @param {Object} ability Ability object. + * @param {Object} wpContext WP context object. + * @return {boolean} Final decision. + */ + function applyAbilityExposureFilter( defaultValue, ability, wpContext ) { + const hooks = window?.wp?.hooks; + if ( ! hooks || typeof hooks.applyFilters !== 'function' ) { + return defaultValue; + } + + return Boolean( + hooks.applyFilters( + 'ai.webmcp.isAbilityExposed', + defaultValue, + ability, + wpContext + ) + ); + } + + /** + * Requests execution confirmation for mutating abilities. + * + * @param {{ability: Object, agent: {requestUserInteraction?: Function}|undefined, confirmationLevel: 'default'|'destructive'}} context Confirmation context. + * @return {Promise} True when confirmed. + */ + async function defaultRequestConfirmation( context ) { + const { ability, agent, confirmationLevel } = context; + + if ( ! window || typeof window.confirm !== 'function' ) { + return false; + } + + if ( typeof agent?.requestUserInteraction !== 'function' ) { + return false; + } + + const label = ability.label || ability.name; + const description = ability.description + ? `\n\n${ ability.description }` + : ''; + + if ( confirmationLevel === 'destructive' ) { + const destructiveMessage = `Allow destructive ability execution?\n\n${ label }${ description }`; + const secondPrompt = + 'This ability is marked destructive. Confirm again to continue.'; + + return agent.requestUserInteraction( () => { + // eslint-disable-next-line no-alert + const firstConfirmation = window.confirm( destructiveMessage ); + + if ( ! firstConfirmation ) { + return false; + } + + // eslint-disable-next-line no-alert + return window.confirm( secondPrompt ); + } ); + } + + return agent.requestUserInteraction( () => { + // eslint-disable-next-line no-alert + return window.confirm( + `Allow the agent to run this ability?\n\n${ label }${ description }` + ); + } ); + } + + /** + * Validates and normalizes ability names. + * + * @param {unknown} value Candidate ability name. + * @return {string} Ability name. + */ + function parseAbilityName( value ) { + if ( typeof value !== 'string' || ! value ) { + throw new Error( + 'Ability name is required and must be a non-empty string.' + ); + } + + return value; + } + + /** + * Returns merged tool names. + * + * @return {{discover: string, info: string, execute: string}} Tool names. + */ + function getToolNames() { + return { + ...DEFAULT_TOOL_NAMES, + ...asObject( adapterData.toolNames ), + }; + } + + /** + * Returns true when the value looks like abilities API. + * + * @param {unknown} value Candidate API value. + * @return {boolean} True when shape matches. + */ + function isAbilitiesApi( value ) { + const api = asObject( value ); + + return ( + typeof api.getAbilities === 'function' && + typeof api.getAbility === 'function' && + typeof api.executeAbility === 'function' + ); + } + + /** + * Returns WordPress abilities API from global namespace. + * + * @return {{getAbilities: Function, getAbility: Function, executeAbility: Function}|null} Abilities API or null. + */ + function getGlobalAbilitiesApi() { + const abilitiesApi = window?.wp?.abilities; + + return isAbilitiesApi( abilitiesApi ) ? abilitiesApi : null; + } + + /** + * Imports a registered WordPress script module from the current page import map. + * + * @param {string} specifier Module specifier. + * @return {Promise} Imported module namespace object. + */ + async function importWordPressScriptModule( specifier ) { + return import( + /* webpackIgnore: true */ + specifier + ); + } + + /** + * Resolves abilities API from either window globals or script modules. + * + * @param {{forceReload?: boolean}} options Loader options. + * @return {Promise<{api: {getAbilities: Function, getAbility: Function, executeAbility: Function}|null, source: string, error: string}>} Load result. + */ + function resolveAbilitiesApi( options = {} ) { + const { forceReload = false } = options; + + if ( ! forceReload && abilitiesApiPromise ) { + return abilitiesApiPromise; + } + + abilitiesApiPromise = ( async () => { + const globalApi = getGlobalAbilitiesApi(); + if ( globalApi ) { + return { + api: globalApi, + source: 'window.wp.abilities', + error: '', + }; + } + + const errors = []; + + try { + await importWordPressScriptModule( + '@wordpress/core-abilities' + ); + } catch ( error ) { + errors.push( + `@wordpress/core-abilities: ${ getErrorMessage( error ) }` + ); + } + + try { + const moduleApi = await importWordPressScriptModule( + '@wordpress/abilities' + ); + const abilitiesApi = { + getAbilities: moduleApi?.getAbilities, + getAbility: moduleApi?.getAbility, + executeAbility: moduleApi?.executeAbility, + }; + + if ( isAbilitiesApi( abilitiesApi ) ) { + return { + api: abilitiesApi, + source: '@wordpress/abilities (script module)', + error: '', + }; + } + + errors.push( + '@wordpress/abilities did not expose getAbilities/getAbility/executeAbility.' + ); + } catch ( error ) { + errors.push( + `@wordpress/abilities: ${ getErrorMessage( error ) }` + ); + } + + const globalApiAfterImport = getGlobalAbilitiesApi(); + if ( globalApiAfterImport ) { + return { + api: globalApiAfterImport, + source: 'window.wp.abilities (after module import)', + error: '', + }; + } + + return { + api: null, + source: '', + error: errors.join( ' ' ), + }; + } )(); + + return abilitiesApiPromise; + } + + /** + * Returns debug flag config from global runtime overrides. + * + * Supported forms: + * - `window.aiWebMCPDebug = true|false` + * - `window.aiWebMCPDebug = { enabled: true|false, open: true|false, shimModelContext: true|false }` + * + * @return {Object} Debug config. + */ + function getDebugFlagConfig() { + const debugFlag = window.aiWebMCPDebug; + + if ( typeof debugFlag === 'boolean' ) { + return { enabled: debugFlag }; + } + + return asObject( debugFlag ); + } + + /** + * Returns whether debug panel should be enabled. + * + * @return {boolean} True when enabled. + */ + function shouldEnableDebugPanel() { + const debugFlagConfig = getDebugFlagConfig(); + + if ( typeof debugFlagConfig.enabled === 'boolean' ) { + return debugFlagConfig.enabled; + } + + return Boolean( adapterData.debugPanelEnabled ); + } + + /** + * Returns whether the panel should start open. + * + * @return {boolean} True when open. + */ + function shouldDebugPanelStartOpen() { + const debugFlagConfig = getDebugFlagConfig(); + + if ( typeof debugFlagConfig.open === 'boolean' ) { + return debugFlagConfig.open; + } + + return true; + } + + /** + * Returns whether model context shim should auto-install for debugging. + * + * @return {boolean} True when shim should auto-install. + */ + function shouldAutoInstallModelContextShim() { + const debugFlagConfig = getDebugFlagConfig(); + return Boolean( debugFlagConfig.shimModelContext ); + } + + /** + * Returns whether value looks like WebMCP model context. + * + * @param {unknown} value Candidate model context. + * @return {boolean} True when valid. + */ + function isModelContext( value ) { + const context = asObject( value ); + return typeof context.provideContext === 'function'; + } + + /** + * Creates a local test model context shim for non-WebMCP browsers. + * + * @return {{provideContext: Function, getContext: Function, executeTool: Function}} Shim. + */ + function createModelContextShim() { + const shimState = { + context: { + tools: [], + }, + }; + + return { + provideContext( context = {} ) { + const nextContext = asObject( context ); + shimState.context = { + ...nextContext, + tools: Array.isArray( nextContext.tools ) + ? nextContext.tools + : [], + }; + }, + getContext() { + const currentContext = asObject( shimState.context ); + return { + ...currentContext, + tools: Array.isArray( currentContext.tools ) + ? [ ...currentContext.tools ] + : [], + }; + }, + async executeTool( name, input = {}, agent ) { + if ( typeof name !== 'string' || ! name ) { + throw new Error( + 'Tool name is required and must be a non-empty string.' + ); + } + + const tools = Array.isArray( shimState.context.tools ) + ? shimState.context.tools + : []; + const tool = tools.find( + ( candidate ) => candidate?.name === name + ); + + if ( ! tool || typeof tool.execute !== 'function' ) { + throw new Error( `Tool not registered: ${ name }` ); + } + + return tool.execute( input, agent ); + }, + }; + } + + /** + * Installs a local model context shim for debugging. + * + * @return {{provideContext: Function, getContext: Function, executeTool: Function}} Shim. + */ + function installModelContextShim() { + if ( ! modelContextShim ) { + modelContextShim = createModelContextShim(); + } + + try { + Object.defineProperty( window.navigator, 'modelContext', { + configurable: true, + get() { + return modelContextShim; + }, + } ); + } catch { + // Ignore; some browser implementations make navigator properties immutable. + } + + debugState.modelContextShimInstalled = true; + + return modelContextShim; + } + + /** + * Returns model context from browser or debug shim. + * + * @return {{provideContext: Function}|null} Model context. + */ + function getModelContext() { + const modelContext = window?.navigator?.modelContext; + + if ( isModelContext( modelContext ) ) { + debugState.modelContextSource = + modelContext === modelContextShim + ? 'debug-shim (navigator.modelContext)' + : 'navigator.modelContext'; + debugState.modelContextShimInstalled = + modelContext === modelContextShim; + return modelContext; + } + + if ( modelContextShim && isModelContext( modelContextShim ) ) { + debugState.modelContextSource = 'debug-shim'; + debugState.modelContextShimInstalled = true; + return modelContextShim; + } + + if ( shouldAutoInstallModelContextShim() ) { + const shim = installModelContextShim(); + debugState.modelContextSource = 'debug-shim'; + return shim; + } + + debugState.modelContextSource = ''; + debugState.modelContextShimInstalled = false; + return null; + } + + /** + * Renders debug status data. + */ + function renderDebugPanelStatus() { + if ( ! debugPanelElements ) { + return; + } + + const status = { + modelContextAvailable: debugState.modelContextAvailable, + modelContextSource: debugState.modelContextSource || null, + modelContextShimInstalled: debugState.modelContextShimInstalled, + abilitiesApiAvailable: debugState.abilitiesApiAvailable, + abilitiesApiSource: debugState.abilitiesApiSource || null, + abilitiesApiError: debugState.abilitiesApiError || null, + registrationSucceeded: debugState.registrationSucceeded, + registrationError: debugState.registrationError || null, + registrationAttempts: debugState.registrationAttempts, + toolNames: debugState.toolNames, + wpContext: debugState.context, + flags: { + optionEnabled: Boolean( adapterData.debugPanelEnabled ), + jsFlag: window.aiWebMCPDebug, + }, + }; + + debugPanelElements.status.textContent = toDebugText( status ); + } + + /** + * Sets debug panel output. + * + * @param {unknown} value Output value. + */ + function setDebugPanelOutput( value ) { + if ( ! debugPanelElements ) { + return; + } + + debugPanelElements.output.textContent = toDebugText( value ); + } + + /** + * Returns a registered tool by name. + * + * @param {string} toolName Registered tool name. + * @return {Object} Tool object. + */ + function getRegisteredToolByName( toolName ) { + const tool = registeredTools.find( + ( candidate ) => candidate.name === toolName + ); + + if ( ! tool ) { + throw new Error( `Tool not registered: ${ toolName }` ); + } + + return tool; + } + + /** + * Resolves a layered key (`discover`, `info`, `execute`) or direct tool name. + * + * @param {string} toolKeyOrName Layered key or tool name. + * @return {string} Tool name. + */ + function resolveToolName( toolKeyOrName ) { + const toolNames = getToolNames(); + const maybeName = toolNames[ toolKeyOrName ] || toolKeyOrName; + + if ( typeof maybeName !== 'string' || ! maybeName ) { + throw new Error( + 'Tool key/name must resolve to a non-empty string.' + ); + } + + return maybeName; + } + + /** + * Ensures tools are registered before debug action execution. + * + * @return {Promise} Resolves when tools are available. + */ + async function ensureToolsRegistered() { + if ( ! debugState.registrationSucceeded ) { + await registerAbilitiesWebMCPAdapter(); + } + + if ( ! debugState.registrationSucceeded ) { + throw new Error( + debugState.registrationError || + 'WebMCP tools were not registered.' + ); + } + } + + /** + * Handles debug panel action execution. + * + * @param {() => Promise} action Async action callback. + */ + async function runDebugPanelAction( action ) { + try { + const result = await action(); + setDebugPanelOutput( result ); + } catch ( error ) { + setDebugPanelOutput( { + error: getErrorMessage( error ), + } ); + } + } + + /** + * Renders panel open/closed state. + */ + function renderDebugPanelVisibility() { + if ( ! debugPanelElements ) { + return; + } + + debugPanelElements.body.style.display = debugState.open + ? 'block' + : 'none'; + debugPanelElements.toggle.textContent = debugState.open + ? 'Collapse' + : 'Expand'; + } + + /** + * Creates the debug panel DOM. + */ + function createDebugPanel() { + if ( debugPanelElements || ! document.body ) { + return; + } + + const panel = document.createElement( 'div' ); + panel.id = 'ai-webmcp-debug-panel'; + panel.style.cssText = + 'position:fixed;right:16px;bottom:16px;z-index:999999;width:min(440px,calc(100vw - 32px));max-height:75vh;overflow:auto;background:#111827;color:#f9fafb;border:1px solid #374151;border-radius:8px;box-shadow:0 12px 40px rgba(0,0,0,.35);font:12px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;'; + + panel.innerHTML = ` +
+ WebMCP Debug Panel +
+ + +
+
+
+
+ + + +
+
+ + + +
+
+
Status
+

+				
+
+
Last Result
+
No calls yet.
+
+
+ `; + + document.body.appendChild( panel ); + + const body = panel.querySelector( '[data-ai-webmcp-body]' ); + const status = panel.querySelector( '[data-ai-webmcp-status]' ); + const output = panel.querySelector( '[data-ai-webmcp-output]' ); + const toggle = panel.querySelector( + '[data-ai-webmcp-action="toggle"]' + ); + + if ( ! body || ! status || ! output || ! toggle ) { + return; + } + + debugPanelElements = { + panel, + body, + status, + output, + toggle, + }; + + panel.addEventListener( 'click', ( event ) => { + const target = event.target; + if ( ! target || typeof target.getAttribute !== 'function' ) { + return; + } + + const action = target.getAttribute( 'data-ai-webmcp-action' ); + if ( ! action ) { + return; + } + + if ( action === 'toggle' ) { + debugState.open = ! debugState.open; + renderDebugPanelVisibility(); + return; + } + + if ( action === 'close' ) { + window.aiWebMCPDebug = false; + debugState.enabled = false; + destroyDebugPanel(); + return; + } + + if ( action === 'refresh' ) { + debugState.context = getWordPressWebMcpContext(); + renderDebugPanelStatus(); + setDebugPanelOutput( 'Refreshed context status.' ); + return; + } + + if ( action === 'register' ) { + runDebugPanelAction( async () => { + const isRegistered = await registerAbilitiesWebMCPAdapter( { + forceReloadAbilities: true, + } ); + + return { + registrationSucceeded: isRegistered, + registrationError: debugState.registrationError || null, + }; + } ); + return; + } + + if ( action === 'install-shim' ) { + runDebugPanelAction( async () => { + installModelContextShim(); + await registerAbilitiesWebMCPAdapter(); + + return { + modelContextSource: + debugState.modelContextSource || null, + registrationSucceeded: debugState.registrationSucceeded, + registrationError: debugState.registrationError || null, + }; + } ); + return; + } + + if ( action === 'discover' ) { + runDebugPanelAction( async () => { + await ensureToolsRegistered(); + const discoverTool = getRegisteredToolByName( + resolveToolName( 'discover' ) + ); + + return discoverTool.execute(); + } ); + return; + } + + if ( action === 'info-post-details' ) { + runDebugPanelAction( async () => { + await ensureToolsRegistered(); + const infoTool = getRegisteredToolByName( + resolveToolName( 'info' ) + ); + + return infoTool.execute( { name: 'ai/get-post-details' } ); + } ); + return; + } + + if ( action === 'execute-post-details' ) { + runDebugPanelAction( async () => { + await ensureToolsRegistered(); + const executeTool = getRegisteredToolByName( + resolveToolName( 'execute' ) + ); + + return executeTool.execute( + { + name: 'ai/get-post-details', + input: { + post_id: 1, + fields: [ 'title', 'slug' ], + }, + }, + { + requestUserInteraction: async ( callback ) => + callback(), + } + ); + } ); + } + } ); + + renderDebugPanelVisibility(); + renderDebugPanelStatus(); + } + + /** + * Destroys the debug panel. + */ + function destroyDebugPanel() { + if ( ! debugPanelElements ) { + return; + } + + debugPanelElements.panel.remove(); + debugPanelElements = null; + } + + /** + * Synchronizes panel DOM with debug state. + */ + function syncDebugPanel() { + if ( ! debugState.enabled ) { + destroyDebugPanel(); + return; + } + + createDebugPanel(); + renderDebugPanelVisibility(); + renderDebugPanelStatus(); + } + + /** + * Creates layered WebMCP tools for abilities. + * + * @param {{getAbilities: Function, getAbility: Function, executeAbility: Function}} abilitiesApi Abilities API. + * @return {Array} Tool definitions. + */ + function createAbilitiesWebMcpTools( abilitiesApi ) { + const toolNames = getToolNames(); + const isAbilityExposed = ( ability, wpContext ) => + applyAbilityExposureFilter( + isAbilityPublicForAgents( ability ) && + doesAbilityMatchWpContext( ability, wpContext ), + ability, + wpContext + ); + + /** + * Resolves ability and enforces exposure rules. + * + * @param {unknown} nameValue Ability name input. + * @param {Object} wpContext Current WP context. + * @return {Object} Ability object. + */ + const resolveAbility = ( nameValue, wpContext ) => { + const name = parseAbilityName( nameValue ); + const ability = abilitiesApi.getAbility( name ); + + if ( ! ability ) { + throw new Error( `Ability not found: ${ name }` ); + } + + if ( ! isAbilityExposed( ability, wpContext ) ) { + throw new Error( + `Ability is not exposed to agents: ${ name }` + ); + } + + return ability; + }; + + return [ + { + name: toolNames.discover, + description: + 'List abilities available in this WordPress context for agents.', + inputSchema: { + type: 'object', + properties: { + category: { + type: 'string', + description: 'Optional ability category filter.', + }, + }, + additionalProperties: false, + }, + execute: ( rawInput = {} ) => { + const input = asObject( rawInput ); + const wpContext = getWordPressWebMcpContext(); + const { category } = input; + const queryArgs = + typeof category === 'string' && category + ? { category } + : {}; + const abilities = abilitiesApi.getAbilities( queryArgs ); + const filteredAbilities = abilities.filter( ( ability ) => + isAbilityExposed( ability, wpContext ) + ); + + return toTextContentResult( + filteredAbilities.map( ( ability ) => ( { + name: ability.name, + label: ability.label, + description: ability.description, + category: ability.category, + } ) ) + ); + }, + }, + { + name: toolNames.info, + description: 'Get schema and metadata for an ability by name.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: + 'Ability name in namespace/ability format.', + }, + }, + required: [ 'name' ], + additionalProperties: false, + }, + execute: ( rawInput = {} ) => { + const input = asObject( rawInput ); + const wpContext = getWordPressWebMcpContext(); + const ability = resolveAbility( input.name, wpContext ); + + return toTextContentResult( { + name: ability.name, + label: ability.label, + description: ability.description, + category: ability.category, + input_schema: ability.input_schema, + output_schema: ability.output_schema, + meta: ability.meta, + } ); + }, + }, + { + name: toolNames.execute, + description: + 'Execute an ability by name with optional JSON input.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: + 'Ability name in namespace/ability format.', + }, + input: { + description: 'Ability input payload.', + type: [ + 'object', + 'array', + 'string', + 'number', + 'boolean', + 'null', + ], + }, + }, + required: [ 'name' ], + additionalProperties: false, + }, + execute: async ( rawInput = {}, agent ) => { + const input = asObject( rawInput ); + const wpContext = getWordPressWebMcpContext(); + const ability = resolveAbility( input.name, wpContext ); + const confirmationLevel = + getAbilityConfirmationLevel( ability ); + + if ( confirmationLevel !== 'none' ) { + const isConfirmed = await defaultRequestConfirmation( { + ability, + agent, + confirmationLevel, + } ); + + if ( ! isConfirmed ) { + throw new Error( 'Ability execution canceled.' ); + } + } + + const result = await abilitiesApi.executeAbility( + ability.name, + input.input + ); + + return toTextContentResult( result ); + }, + }, + ]; + } + + /** + * Registers layered WebMCP tools with navigator.modelContext. + * + * @param {{forceReloadAbilities?: boolean}} options Adapter options. + * @return {Promise} True when tools are registered. + */ + async function registerAbilitiesWebMCPAdapter( options = {} ) { + debugState.enabled = shouldEnableDebugPanel(); + debugState.open = shouldDebugPanelStartOpen(); + debugState.context = getWordPressWebMcpContext(); + debugState.registrationAttempts += 1; + debugState.registrationSucceeded = false; + debugState.registrationError = ''; + debugState.toolNames = []; + syncDebugPanel(); + + const modelContext = getModelContext(); + debugState.modelContextAvailable = Boolean( modelContext ); + + if ( ! debugState.modelContextAvailable ) { + debugState.registrationError = + 'navigator.modelContext.provideContext is unavailable. Use a WebMCP-enabled browser environment or enable the debug shim.'; + syncDebugPanel(); + return false; + } + + const abilitiesApiResult = await resolveAbilitiesApi( { + forceReload: Boolean( options.forceReloadAbilities ), + } ); + const abilitiesApi = abilitiesApiResult.api; + + debugState.abilitiesApiAvailable = Boolean( abilitiesApi ); + debugState.abilitiesApiSource = abilitiesApiResult.source || ''; + debugState.abilitiesApiError = abilitiesApiResult.error || ''; + + if ( ! abilitiesApi ) { + debugState.registrationError = + 'WordPress abilities API is unavailable. Ensure Gutenberg/core abilities are loaded in this admin page.'; + syncDebugPanel(); + return false; + } + + const tools = createAbilitiesWebMcpTools( abilitiesApi ); + registeredTools = tools; + + try { + modelContext.provideContext( { tools } ); + } catch ( error ) { + debugState.registrationError = `modelContext.provideContext failed: ${ getErrorMessage( + error + ) }`; + syncDebugPanel(); + return false; + } + + debugState.registrationSucceeded = true; + debugState.registrationError = ''; + debugState.toolNames = tools.map( ( tool ) => tool.name ); + syncDebugPanel(); + + return true; + } + + window.aiWebMCPAdapterDebug = { + enable() { + window.aiWebMCPDebug = true; + debugState.enabled = true; + syncDebugPanel(); + void registerAbilitiesWebMCPAdapter(); + }, + disable() { + window.aiWebMCPDebug = false; + debugState.enabled = false; + syncDebugPanel(); + }, + async register( options = {} ) { + return registerAbilitiesWebMCPAdapter( { + forceReloadAbilities: Boolean( + asObject( options ).forceReloadAbilities + ), + } ); + }, + installModelContextShim() { + installModelContextShim(); + syncDebugPanel(); + }, + async callTool( toolKeyOrName, input = {}, agent ) { + await ensureToolsRegistered(); + const toolName = resolveToolName( toolKeyOrName ); + const tool = getRegisteredToolByName( toolName ); + + return tool.execute( input, agent ); + }, + refresh() { + debugState.context = getWordPressWebMcpContext(); + renderDebugPanelStatus(); + }, + getState() { + return { + ...debugState, + toolNames: [ ...debugState.toolNames ], + }; + }, + }; + + void registerAbilitiesWebMCPAdapter(); +} )(); diff --git a/tests/Integration/Includes/Experiment_LoaderTest.php b/tests/Integration/Includes/Experiment_LoaderTest.php index 34bc6e3a8..3220ac7d1 100644 --- a/tests/Integration/Includes/Experiment_LoaderTest.php +++ b/tests/Integration/Includes/Experiment_LoaderTest.php @@ -131,6 +131,10 @@ public function test_register_default_experiments() { $this->registry->has_experiment( 'title-generation' ), 'Title generation experiment should be registered' ); + $this->assertTrue( + $this->registry->has_experiment( 'webmcp-adapter' ), + 'WebMCP adapter experiment should be registered' + ); $abilities_explorer_experiment = $this->registry->get_experiment( 'abilities-explorer' ); $this->assertNotNull( $abilities_explorer_experiment, 'Abilities explorer experiment should exist' ); @@ -155,6 +159,10 @@ public function test_register_default_experiments() { $title_experiment = $this->registry->get_experiment( 'title-generation' ); $this->assertNotNull( $title_experiment, 'Title generation experiment should exist' ); $this->assertEquals( 'title-generation', $title_experiment->get_id() ); + + $webmcp_adapter_experiment = $this->registry->get_experiment( 'webmcp-adapter' ); + $this->assertNotNull( $webmcp_adapter_experiment, 'WebMCP adapter experiment should exist' ); + $this->assertEquals( 'webmcp-adapter', $webmcp_adapter_experiment->get_id() ); } /** diff --git a/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php b/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php new file mode 100644 index 000000000..9bb94a363 --- /dev/null +++ b/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php @@ -0,0 +1,383 @@ +registry = new Experiment_Registry(); + } + + /** + * Tear down test case. + * + * @since 0.4.0 + */ + public function tearDown(): void { + delete_option( 'ai_experiments_enabled' ); + delete_option( 'ai_experiment_webmcp-adapter_enabled' ); + delete_option( 'ai_experiment_webmcp-adapter_field_debug_panel_enabled' ); + remove_all_filters( 'ai_webmcp_adapter_allowed_hooks' ); + remove_all_filters( 'ai_webmcp_adapter_tool_names' ); + remove_all_filters( 'ai_webmcp_adapter_is_available' ); + remove_all_filters( 'ai_experiments_pre_has_valid_credentials_check' ); + remove_all_filters( 'sanitize_option_ai_experiment_webmcp-adapter_enabled' ); + + wp_dequeue_script( 'ai_webmcp_adapter' ); + wp_deregister_script( 'wp-hooks' ); + wp_deregister_script( 'wp-abilities' ); + wp_deregister_script( 'wp-core-abilities' ); + wp_set_current_user( 0 ); + unset( $_GET['page'] ); + + parent::tearDown(); + } + + /** + * Tests experiment metadata. + * + * @since 0.4.0 + */ + public function test_experiment_metadata() { + $experiment = new WebMCP(); + + $this->assertEquals( 'webmcp-adapter', $experiment->get_id() ); + $this->assertEquals( 'WebMCP Adapter', $experiment->get_label() ); + $this->assertNotEmpty( $experiment->get_description() ); + } + + /** + * Tests assets are not enqueued on unsupported screens. + * + * @since 0.4.0 + */ + public function test_assets_not_enqueued_on_unsupported_screen() { + $experiment = new WebMCP(); + + wp_register_script( 'wp-hooks', '', array(), '1.0.0', true ); + wp_register_script( 'wp-abilities', '', array(), '1.0.0', true ); + wp_register_script( 'wp-core-abilities', '', array(), '1.0.0', true ); + + $experiment->enqueue_assets( 'plugins.php' ); + + $this->assertFalse( wp_script_is( 'ai_webmcp_adapter', 'enqueued' ) ); + } + + /** + * Tests script dependencies omit unregistered ability handles. + * + * @since 0.4.0 + */ + public function test_assets_enqueued_when_dependencies_missing() { + $experiment = new WebMCP(); + $reflection = new \ReflectionClass( $experiment ); + $method = $reflection->getMethod( 'get_script_dependencies' ); + $method->setAccessible( true ); + + $dependencies = $method->invoke( $experiment ); + + $this->assertIsArray( $dependencies ); + $this->assertNotContains( 'wp-abilities', $dependencies ); + $this->assertNotContains( 'wp-core-abilities', $dependencies ); + } + + /** + * Tests Gutenberg compatibility hook is allowed for enqueue decisions. + * + * @since 0.4.0 + */ + public function test_assets_enqueued_on_gutenberg_compat_hook() { + $experiment = new WebMCP(); + $reflection = new \ReflectionClass( $experiment ); + $method = $reflection->getMethod( 'should_enqueue_for_hook' ); + $method->setAccessible( true ); + + $this->assertTrue( $method->invoke( $experiment, 'appearance_page_gutenberg-edit-site' ) ); + } + + /** + * Tests WordPress context payload includes expected keys and value types. + * + * @since 0.4.0 + */ + public function test_assets_enqueued_with_localized_context() { + $experiment = new WebMCP(); + $reflection = new \ReflectionClass( $experiment ); + $method = $reflection->getMethod( 'get_wp_context' ); + $method->setAccessible( true ); + + set_current_screen( 'post' ); + $context = $method->invoke( $experiment ); + + $this->assertIsArray( $context ); + $this->assertArrayHasKey( 'screen', $context ); + $this->assertArrayHasKey( 'adminPage', $context ); + $this->assertArrayHasKey( 'postType', $context ); + $this->assertArrayHasKey( 'query', $context ); + $this->assertIsString( $context['screen'] ); + $this->assertIsString( $context['adminPage'] ); + $this->assertIsString( $context['postType'] ); + $this->assertIsArray( $context['query'] ); + } + + /** + * Tests custom debug panel setting registration. + * + * @since 0.4.0 + */ + public function test_register_settings_registers_debug_panel_setting() { + $experiment = new WebMCP(); + $experiment->register_settings(); + + global $wp_registered_settings; + + $this->assertArrayHasKey( + 'ai_experiment_webmcp-adapter_field_debug_panel_enabled', + $wp_registered_settings + ); + } + + /** + * Tests custom debug panel setting field rendering. + * + * @since 0.4.0 + */ + public function test_render_settings_fields_outputs_debug_panel_toggle() { + $experiment = new WebMCP(); + + ob_start(); + $experiment->render_settings_fields(); + $output = ob_get_clean(); + + $this->assertIsString( $output ); + $this->assertStringContainsString( + 'Enable WebMCP debug panel', + $output + ); + $this->assertStringContainsString( + 'ai_experiment_webmcp-adapter_field_debug_panel_enabled', + $output + ); + } + + /** + * Tests the experiment exposes settings UI in the admin page. + * + * @since 0.4.0 + */ + public function test_has_settings_returns_true() { + $experiment = new WebMCP(); + + $this->assertTrue( $experiment->has_settings() ); + } + + /** + * Tests experiment cannot be enabled when dependencies are unavailable. + * + * @since 0.4.0 + */ + public function test_experiment_is_disabled_when_unavailable() { + $callback = static function () { + return false; + }; + add_filter( 'ai_webmcp_adapter_is_available', $callback ); + + $experiment = new WebMCP(); + $this->assertFalse( $experiment->is_enabled() ); + + remove_filter( 'ai_webmcp_adapter_is_available', $callback ); + } + + /** + * Tests Gutenberg dependency check uses the active plugins list. + * + * @since 0.4.0 + */ + public function test_gutenberg_dependency_uses_active_plugins_list() { + if ( defined( 'GUTENBERG_VERSION' ) ) { + $this->markTestSkipped( 'Environment already defines GUTENBERG_VERSION.' ); + } + + $experiment = new WebMCP(); + $reflection = new \ReflectionClass( $experiment ); + $method = $reflection->getMethod( 'is_gutenberg_active' ); + $method->setAccessible( true ); + + $original_active_plugins = get_option( 'active_plugins', array() ); + + try { + update_option( 'active_plugins', array() ); + $this->assertFalse( $method->invoke( $experiment ) ); + + update_option( 'active_plugins', array( 'gutenberg/gutenberg.php' ) ); + $this->assertTrue( $method->invoke( $experiment ) ); + } finally { + update_option( 'active_plugins', $original_active_plugins ); + } + } + + /** + * Tests enabled setting cannot be saved when dependencies are unavailable. + * + * @since 0.4.0 + */ + public function test_enabled_setting_forced_off_when_unavailable() { + $experiment = new WebMCP(); + $experiment->register_settings(); + + $callback = static function () { + return false; + }; + add_filter( 'ai_webmcp_adapter_is_available', $callback ); + + // phpcs:disable WordPress.NamingConventions.ValidHookName.UseUnderscores -- Option name contains a required hyphen from experiment ID. + $sanitized_value = apply_filters( + 'sanitize_option_ai_experiment_webmcp-adapter_enabled', + true, + 'ai_experiment_webmcp-adapter_enabled', + true + ); + // phpcs:enable WordPress.NamingConventions.ValidHookName.UseUnderscores + + $this->assertFalse( $sanitized_value ); + $errors = get_settings_errors( Settings_Registration::OPTION_GROUP ); + $this->assertNotEmpty( $errors ); + $this->assertStringContainsString( 'Abilities API', $errors[0]['message'] ); + + remove_filter( 'ai_webmcp_adapter_is_available', $callback ); + } + + /** + * Tests settings page disables WebMCP toggle when dependencies are unavailable. + * + * @since 0.4.0 + */ + public function test_settings_page_disables_toggle_when_unavailable() { + $callback = static function () { + return false; + }; + add_filter( 'ai_webmcp_adapter_is_available', $callback ); + add_filter( 'ai_experiments_pre_has_valid_credentials_check', '__return_true' ); + + $this->registry->register_experiment( new WebMCP() ); + $settings_page = new Settings_Page( $this->registry ); + $admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_user_id ); + + // Mirror core admin globals expected by get_admin_page_title() in test context. + $GLOBALS['plugin_page'] = 'ai-experiments'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Test fixture setup for core admin state. + $_GET['page'] = 'ai-experiments'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Test fixture setup. + + ob_start(); + $settings_page->render_page(); + $output = ob_get_clean(); + + $this->assertIsString( $output ); + $this->assertStringContainsString( 'id="ai_experiment_webmcp-adapter_enabled"', $output ); + $this->assertStringContainsString( 'disabled=\'disabled\'', $output ); + $this->assertStringContainsString( 'Requires the Gutenberg plugin', $output ); + + remove_filter( 'ai_webmcp_adapter_is_available', $callback ); + } + + /** + * Tests allowed hooks filter can prevent enqueuing. + * + * @since 0.4.0 + */ + public function test_allowed_hooks_filter_can_prevent_enqueue() { + $experiment = new WebMCP(); + $callback = static function () { + return array(); + }; + + add_filter( + 'ai_webmcp_adapter_allowed_hooks', + $callback + ); + + $experiment->enqueue_assets( 'post.php' ); + $this->assertFalse( wp_script_is( 'ai_webmcp_adapter', 'enqueued' ) ); + + remove_filter( 'ai_webmcp_adapter_allowed_hooks', $callback ); + } + + /** + * Tests tool name filter applies custom names and preserves defaults on invalid values. + * + * @since 0.4.0 + */ + public function test_tool_name_filter_applies_with_fallbacks() { + $experiment = new WebMCP(); + $reflection = new \ReflectionClass( $experiment ); + $method = $reflection->getMethod( 'get_tool_names' ); + $method->setAccessible( true ); + $callback = static function () { + return array( + 'discover' => 'custom-discover', + 'info' => '', + 'execute' => 'custom-execute', + ); + }; + + add_filter( + 'ai_webmcp_adapter_tool_names', + $callback + ); + + $tool_names = $method->invoke( $experiment ); + $this->assertIsArray( $tool_names ); + $this->assertSame( 'custom-discover', $tool_names['discover'] ); + $this->assertSame( 'wp-get-ability-info', $tool_names['info'] ); + $this->assertSame( 'custom-execute', $tool_names['execute'] ); + + remove_filter( 'ai_webmcp_adapter_tool_names', $callback ); + } + + /** + * Tests experiment can be registered in registry. + * + * @since 0.4.0 + */ + public function test_experiment_registration_in_registry() { + $experiment = new WebMCP(); + $this->registry->register_experiment( $experiment ); + + $this->assertTrue( $this->registry->has_experiment( 'webmcp-adapter' ) ); + + $registered = $this->registry->get_experiment( 'webmcp-adapter' ); + $this->assertInstanceOf( WebMCP::class, $registered ); + } +} diff --git a/webpack.config.js b/webpack.config.js index e6327cb3b..2bae7958f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -59,6 +59,11 @@ module.exports = { 'src/experiments/alt-text-generation', 'media.ts' ), + 'experiments/webmcp-adapter': path.resolve( + process.cwd(), + 'src/experiments/webmcp-adapter', + 'index.js' + ), }, plugins: [