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