From 45595c7e94952b88a70e10e01baad1bfe4d696b5 Mon Sep 17 00:00:00 2001 From: Mihai Dragomirescu Date: Wed, 30 Sep 2026 17:43:02 +0300 Subject: [PATCH 1/8] Add WebMCP experiment Registers a curated, opt-in set of WordPress abilities as WebMCP tools on the page through document.modelContext, one registerTool call per tool, and executes them through a REST route that runs the ability's own permission and input checks on the server. The Abilities API stays the single registry. Two page contexts (wp-admin, front end) with separate allowlists and a per-page cap of 30 tools, because agent browsers cap what a page may register. Exposure is explicit: an ability opts in through its meta, a filter allows it, or the site owner lists it in the experiment settings. Tool names carry the ability name with __ in place of /, since a URL-encoded slash never reaches WordPress on stock Apache. Two tokens travel with each execution: the wp_rest nonce core requires in X-WP-Nonce and the experiment's own token in X-WPAI-WebMCP-Nonce. See #448. Keeps #224 as prior art. Co-Authored-By: Claude Fable 5.1 --- docs/experiments/webmcp.md | 94 ++++ includes/Experiments/Experiments.php | 1 + .../Experiments/WebMCP/REST_Controller.php | 262 +++++++++++ includes/Experiments/WebMCP/Tool_Curator.php | 409 ++++++++++++++++++ includes/Experiments/WebMCP/WebMCP.php | 196 +++++++++ src/experiments/webmcp/index.ts | 235 ++++++++++ .../WebMCP/REST_ControllerTest.php | 224 ++++++++++ .../Experiments/WebMCP/Tool_CuratorTest.php | 251 +++++++++++ .../Experiments/WebMCP/WebMCPTest.php | 133 ++++++ webpack.config.js | 5 + 10 files changed, 1810 insertions(+) create mode 100644 docs/experiments/webmcp.md create mode 100644 includes/Experiments/WebMCP/REST_Controller.php create mode 100644 includes/Experiments/WebMCP/Tool_Curator.php create mode 100644 includes/Experiments/WebMCP/WebMCP.php create mode 100644 src/experiments/webmcp/index.ts create mode 100644 tests/Integration/Includes/Experiments/WebMCP/REST_ControllerTest.php create mode 100644 tests/Integration/Includes/Experiments/WebMCP/Tool_CuratorTest.php create mode 100644 tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php diff --git a/docs/experiments/webmcp.md b/docs/experiments/webmcp.md new file mode 100644 index 000000000..611ec103a --- /dev/null +++ b/docs/experiments/webmcp.md @@ -0,0 +1,94 @@ +# WebMCP + +## Summary + +The WebMCP experiment registers a curated set of WordPress abilities as WebMCP tools on the page, so an agent browser (ChatGPT's in-app browser, Chrome builds with WebMCP) can call them through `document.modelContext`. The Abilities API stays the single registry: the experiment decides which abilities a page exposes, hands them to the browser one `registerTool` call at a time, and executes them through a REST route that runs the ability's own permission and input checks on the server. + +Nothing is exposed until an ability opts in, a filter allows it, or the site owner lists it in the experiment's settings. + +## Overview + +When enabled, the experiment does three things. + +1. On wp-admin screens (for logged-in users) and on the front end (only when something is exposed to visitors) it enqueues a small bridge script with the REST URLs and two tokens. +2. The bridge asks `GET /wp-json/ai/v1/webmcp/tools?context=admin|visitor` for the tools this page exposes and registers each one with `document.modelContext.registerTool()`. +3. When the agent calls a tool, the bridge posts to `POST /wp-json/ai/v1/webmcp/execute` and returns the ability's result as text content. + +The browser only ever sees names, descriptions and input schemas. Permission callbacks, input validation and execution run in WordPress through `WP_Ability::execute()`. + +## Contexts + +Agent browsers cap the number of tools a page may register. In testing, a few hundred tools disabled WebMCP for the document with no error, while about thirty worked. A logged-in editor and a visitor also need different tools, so the experiment keeps two allowlists: + +- `admin`: wp-admin screens, logged-in users. +- `visitor`: the front end. The script is not loaded there unless the visitor list is non-empty. + +The context is decided by the surface, not the login: a logged-in user reading the front end gets the visitor set. + +The cap defaults to 30 tools and can be changed with the `wpai_webmcp_max_tools` filter. Tools beyond the cap are left out, and the tools response reports how many in `truncated`. + +## Exposing an ability + +An ability is exposed in a context when any of these holds: + +1. **Opt-in on the ability.** Add `webmcp` to its `meta` when registering it: + + ```php + 'meta' => array( + 'webmcp' => array( 'admin' => true, 'visitor' => false ), + // or 'webmcp' => true (every context), or 'webmcp' => 'admin' + ), + ``` + +2. **The `wpai_webmcp_exposed_abilities` filter.** + + ```php + add_filter( 'wpai_webmcp_exposed_abilities', function ( array $names, string $context ) { + if ( 'admin' === $context ) { + $names[] = 'core/get-site-info'; + } + return $names; + }, 10, 2 ); + ``` + +3. **The experiment's settings.** Two text fields under Settings, AI, WebMCP: abilities exposed in wp-admin and abilities exposed to visitors, comma-separated ability names. + +Names that do not resolve to a registered ability are dropped silently. Abilities the current user may not run (per the ability's permission callback) are left out of the list, so the agent never sees a tool that would only answer with a permission error. + +## Tool names + +Ability names contain `/`, and a URL-encoded slash is rejected by stock Apache before WordPress runs (`AllowEncodedSlashes Off` is the default). Tool names therefore travel with `__` in place of `/`: the ability `core/get-post` is the tool `core__get-post`. The execute route maps the name back. An ability name must not itself contain `__`. + +## Authentication + +Requests from the bridge carry two tokens: + +- `X-WP-Nonce`: the `wp_rest` nonce that authenticates the cookie session. Core rejects any other nonce in this header before a route runs. +- `X-WPAI-WebMCP-Nonce`: the experiment's own token (action `wpai_webmcp_execute`), required on every execution. + +Both are printed with the page and refreshed from `GET /wp-json/ai/v1/webmcp/nonce` when an execution answers 403, so a page that stays open keeps working. + +## REST routes + +| Route | Method | Purpose | +| --- | --- | --- | +| `/ai/v1/webmcp/tools?context=admin` | GET | Tools the context exposes for the current user, plus fresh tokens. | +| `/ai/v1/webmcp/execute` | POST | Body: `{ "tool": "core__get-post", "context": "admin", "input": { ... } }`. Returns `{ "tool", "ability", "result" }` or the ability's own `WP_Error`. | +| `/ai/v1/webmcp/nonce` | GET | Fresh tokens. | + +## Hooks + +- `wpai_webmcp_exposed_abilities` (filter): `list $names, string $context`. Adds or removes ability names for a context. +- `wpai_webmcp_max_tools` (filter): `int $max_tools`. Default 30. + +## Testing in an agent browser + +1. Enable the experiment and expose at least one ability. The quickest way is the settings field: WordPress registers `core/get-site-info`, `core/get-user-info` and `core/get-environment-info` on every site, all read-only, so any of them works without another experiment. +2. Open a wp-admin screen in a browser that implements WebMCP. ChatGPT's in-app browser does; in Chrome, WebMCP ships behind a flag in recent builds. +3. Ask the agent to list the site's tools, then to call one. Every write still goes through the ability's permission callback. + +Without such a browser, `document.modelContext` is undefined and the bridge does nothing; the REST routes can be exercised directly with the two headers above. + +## Prior art + +This experiment follows the direction set in [#448](https://github.com/WordPress/ai/issues/448) and keeps [#224](https://github.com/WordPress/ai/pull/224) as prior art. Its three requirements (two tokens, the `__` separator, per-context curation with a cap) come from running a WordPress WebMCP bridge in production against ChatGPT's browser since August 2026. diff --git a/includes/Experiments/Experiments.php b/includes/Experiments/Experiments.php index 40f4975e2..94d03b535 100644 --- a/includes/Experiments/Experiments.php +++ b/includes/Experiments/Experiments.php @@ -49,6 +49,7 @@ final class Experiments { \WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::class, \WordPress\AI\Experiments\Key_Encryption\Key_Encryption::class, \WordPress\AI\Experiments\Markdown_Feeds\Markdown_Feeds::class, + \WordPress\AI\Experiments\WebMCP\WebMCP::class, ); /** diff --git a/includes/Experiments/WebMCP/REST_Controller.php b/includes/Experiments/WebMCP/REST_Controller.php new file mode 100644 index 000000000..5478ed655 --- /dev/null +++ b/includes/Experiments/WebMCP/REST_Controller.php @@ -0,0 +1,262 @@ +curator = $curator; + } + + /** + * Registers the routes. + * + * @since x.x.x + */ + public function register_routes(): void { + $context_arg = array( + 'type' => 'string', + 'enum' => Tool_Curator::get_contexts(), + 'default' => WebMCP::CONTEXT_ADMIN, + ); + + register_rest_route( + self::NAMESPACE, + '/webmcp/tools', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_tools' ), + 'permission_callback' => '__return_true', + 'args' => array( 'context' => $context_arg ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/webmcp/execute', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'execute' ), + 'permission_callback' => array( $this, 'check_execute_nonce' ), + 'args' => array( + 'tool' => array( + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'input' => array( + 'type' => array( 'object', 'array', 'null' ), + 'default' => null, + ), + 'context' => $context_arg, + ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/webmcp/nonce', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_nonces' ), + 'permission_callback' => '__return_true', + ) + ); + } + + /** + * Lists the tools a context exposes for the current user. + * + * The list is public in the sense that anyone may ask; what comes back is + * filtered by the context's exposure rules and by each ability's own + * permission callback for the requesting user. + * + * @since x.x.x + * + * @param \WP_REST_Request $request Request. + * @return \WP_REST_Response Tools, how many the cap removed, and fresh tokens. + */ + public function get_tools( WP_REST_Request $request ): WP_REST_Response { + $context = (string) $request->get_param( 'context' ); + $result = $this->curator->get_tools( $context ); + + return new WP_REST_Response( + array( + 'context' => $context, + 'tools' => $result['tools'], + 'truncated' => $result['truncated'], + 'maxTools' => $this->curator->get_max_tools(), + 'nonce' => wp_create_nonce( self::NONCE_ACTION ), + 'restNonce' => wp_create_nonce( 'wp_rest' ), + ) + ); + } + + /** + * Issues fresh tokens for a page that has stayed open. + * + * @since x.x.x + * + * @return \WP_REST_Response Tokens. + */ + public function get_nonces(): WP_REST_Response { + return new WP_REST_Response( + array( + 'nonce' => wp_create_nonce( self::NONCE_ACTION ), + 'restNonce' => wp_create_nonce( 'wp_rest' ), + ) + ); + } + + /** + * Checks the experiment's own token before an execution runs. + * + * @since x.x.x + * + * @param \WP_REST_Request $request Request. + * @return true|\WP_Error True to proceed. + */ + public function check_execute_nonce( WP_REST_Request $request ) { + $nonce = $request->get_header( self::NONCE_HEADER ); + if ( ! is_string( $nonce ) || ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { + return new WP_Error( + 'wpai_webmcp_invalid_nonce', + __( 'The WebMCP token is missing or has expired. Reload the page and try again.', 'ai' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Executes one exposed ability. + * + * @since x.x.x + * + * @param \WP_REST_Request $request Request. + * @return \WP_REST_Response|\WP_Error Result, or the ability's own error. + */ + public function execute( WP_REST_Request $request ) { + $tool = (string) $request->get_param( 'tool' ); + $context = (string) $request->get_param( 'context' ); + $name = Tool_Curator::to_ability_name( $tool ); + + if ( ! $this->curator->is_exposed( $name, $context ) ) { + return new WP_Error( + 'wpai_webmcp_tool_not_exposed', + sprintf( + /* translators: %s: tool name */ + __( 'The tool "%s" is not exposed on this page.', 'ai' ), + $tool + ), + array( 'status' => 404 ) + ); + } + + $ability = wp_get_ability( $name ); + if ( ! $ability ) { + return new WP_Error( + 'wpai_webmcp_ability_not_found', + sprintf( + /* translators: %s: ability name */ + __( 'The ability "%s" is not registered.', 'ai' ), + $name + ), + array( 'status' => 404 ) + ); + } + + $input = $request->get_param( 'input' ); + $input_schema = $ability->get_input_schema(); + + if ( empty( $input_schema ) ) { + $result = $ability->execute(); + } else { + $result = $ability->execute( is_array( $input ) ? $input : array() ); + } + + if ( is_wp_error( $result ) ) { + $data = $result->get_error_data(); + if ( ! is_array( $data ) || ! isset( $data['status'] ) ) { + $status = false !== strpos( (string) $result->get_error_code(), 'permission' ) ? 403 : 400; + $result->add_data( array( 'status' => $status ) ); + } + + return $result; + } + + return new WP_REST_Response( + array( + 'tool' => $tool, + 'ability' => $name, + 'result' => $result, + ) + ); + } +} diff --git a/includes/Experiments/WebMCP/Tool_Curator.php b/includes/Experiments/WebMCP/Tool_Curator.php new file mode 100644 index 000000000..10bdd1582 --- /dev/null +++ b/includes/Experiments/WebMCP/Tool_Curator.php @@ -0,0 +1,409 @@ + true` (every + * context), `'webmcp' => 'admin'` or `'visitor'` (one context), or + * `'webmcp' => array( 'admin' => true, 'visitor' => false )`. + * 2. The `wpai_webmcp_exposed_abilities` filter adds its name for the context. + * 3. The site owner lists its name in the experiment's settings for the context. + * + * Abilities were written for server-side callers; a browser agent is a + * different trust context, so nothing is exposed by default. + * + * @since x.x.x + */ +class Tool_Curator { + + /** + * What stands in for `/` in a tool name on the wire. + * + * Ability names contain `/`, and a URL-encoded slash is rejected by stock + * Apache before WordPress runs. Tool names travel as `core__get-post`, and + * the execute route maps them back. An ability name must therefore not + * contain `__` itself. + * + * @since x.x.x + */ + public const SEPARATOR = '__'; + + /** + * Default cap on tools registered on one page. + * + * Agent browsers impose a per-page budget; registering a few hundred + * tools disabled WebMCP for the document with no error in testing, while + * about thirty worked. Filterable through `wpai_webmcp_max_tools`. + * + * @since x.x.x + */ + public const DEFAULT_MAX_TOOLS = 30; + + /** + * Feature ID, used to read the experiment's settings options. + * + * @since x.x.x + * @var string + */ + private string $feature_id; + + /** + * Constructor. + * + * @since x.x.x + * + * @param string $feature_id Feature ID of the experiment that owns the settings. + */ + public function __construct( string $feature_id ) { + $this->feature_id = $feature_id; + } + + /** + * Returns the page contexts the experiment knows. + * + * @since x.x.x + * + * @return list Context names. + */ + public static function get_contexts(): array { + return array( WebMCP::CONTEXT_ADMIN, WebMCP::CONTEXT_VISITOR ); + } + + /** + * Whether a string names a known context. + * + * @since x.x.x + * + * @param string $context Candidate. + * @return bool True when known. + */ + public static function is_valid_context( string $context ): bool { + return in_array( $context, self::get_contexts(), true ); + } + + /** + * Converts an ability name to the tool name sent to the browser. + * + * @since x.x.x + * + * @param string $ability_name Ability name, for example `core/get-post`. + * @return string Tool name, for example `core__get-post`. + */ + public static function to_tool_name( string $ability_name ): string { + return str_replace( '/', self::SEPARATOR, $ability_name ); + } + + /** + * Converts a tool name from the browser back to the ability name. + * + * @since x.x.x + * + * @param string $tool_name Tool name, for example `core__get-post`. + * @return string Ability name, for example `core/get-post`. + */ + public static function to_ability_name( string $tool_name ): string { + return str_replace( self::SEPARATOR, '/', $tool_name ); + } + + /** + * Returns the cap on tools per page. + * + * @since x.x.x + * + * @return int Cap, at least 1. + */ + public function get_max_tools(): int { + /** + * Filters how many tools one page may register with the browser. + * + * @since x.x.x + * + * @param int $max_tools Cap. Default 30. + */ + $max_tools = (int) apply_filters( 'wpai_webmcp_max_tools', self::DEFAULT_MAX_TOOLS ); + + return max( 1, $max_tools ); + } + + /** + * Returns the names of the abilities exposed in a context, sorted. + * + * Only registered abilities are returned; names that do not resolve are + * dropped rather than reported, because the settings field is free text. + * + * @since x.x.x + * + * @param string $context Page context. + * @return list Ability names. + */ + public function get_exposed_names( string $context ): array { + if ( ! self::is_valid_context( $context ) ) { + return array(); + } + + $names = array_merge( + $this->get_opted_in_names( $context ), + $this->get_settings_names( $context ) + ); + + /** + * Filters the abilities exposed as WebMCP tools in a page context. + * + * @since x.x.x + * + * @param list $names Ability names, for example `core/get-post`. + * @param string $context Page context: `admin` or `visitor`. + */ + $names = apply_filters( 'wpai_webmcp_exposed_abilities', $names, $context ); + + if ( ! is_array( $names ) ) { + return array(); + } + + // Names come from free text and filters, so they are checked against the + // registry list rather than looked up one by one: wp_get_ability() on an + // unknown name raises a "doing it wrong" notice, which is not the caller's fault here. + $registered = $this->get_registered(); + $exposed = array(); + foreach ( $names as $name ) { + if ( ! is_string( $name ) || '' === $name ) { + continue; + } + if ( ! isset( $registered[ $name ] ) ) { + continue; + } + $exposed[ $name ] = true; + } + + $exposed = array_keys( $exposed ); + sort( $exposed ); + + return $exposed; + } + + /** + * Whether a context exposes anything at all. + * + * @since x.x.x + * + * @param string $context Page context. + * @return bool True when at least one ability is exposed. + */ + public function has_exposed_abilities( string $context ): bool { + return array() !== $this->get_exposed_names( $context ); + } + + /** + * Whether one ability is exposed in a context. + * + * @since x.x.x + * + * @param string $ability_name Ability name. + * @param string $context Page context. + * @return bool True when exposed. + */ + public function is_exposed( string $ability_name, string $context ): bool { + return in_array( $ability_name, $this->get_exposed_names( $context ), true ); + } + + /** + * Returns the tools a page registers, for the current user, within the cap. + * + * Abilities the current user may not run are left out, so the agent never + * sees a tool that would only ever answer with a permission error. + * + * @since x.x.x + * + * @param string $context Page context. + * @return array{tools: list>, truncated: int} Tools and how many were cut by the cap. + */ + public function get_tools( string $context ): array { + $registered = $this->get_registered(); + $tools = array(); + foreach ( $this->get_exposed_names( $context ) as $name ) { + $ability = $registered[ $name ] ?? null; + if ( ! $ability instanceof WP_Ability ) { + continue; + } + if ( ! $this->can_run( $ability ) ) { + continue; + } + $tools[] = $this->convert( $ability ); + } + + $max = $this->get_max_tools(); + $truncated = max( 0, count( $tools ) - $max ); + + return array( + 'tools' => array_slice( $tools, 0, $max ), + 'truncated' => $truncated, + ); + } + + /** + * Converts one ability to the tool shape WebMCP expects. + * + * @since x.x.x + * + * @param \WP_Ability $ability Ability. + * @return array Tool: name, description, inputSchema, annotations. + */ + public function convert( WP_Ability $ability ): array { + $description = trim( (string) $ability->get_description() ); + $label = trim( (string) $ability->get_label() ); + if ( '' === $description ) { + $description = $label; + } elseif ( '' !== $label && 0 !== strpos( $description, $label ) ) { + $description = $label . '. ' . $description; + } + + $input_schema = $ability->get_input_schema(); + if ( ! is_array( $input_schema ) || array() === $input_schema ) { + $input_schema = array( 'type' => 'object' ); + } + if ( ( $input_schema['type'] ?? null ) === 'object' && empty( $input_schema['properties'] ) ) { + // An empty PHP array encodes as `[]`; agents expect `{}` here. + $input_schema['properties'] = new \stdClass(); + } + + $meta = $ability->get_meta(); + $annotations = is_array( $meta['annotations'] ?? null ) ? $meta['annotations'] : array(); + + return array( + 'name' => self::to_tool_name( $ability->get_name() ), + 'description' => $description, + 'inputSchema' => $input_schema, + 'annotations' => array( + 'readOnlyHint' => ! empty( $annotations['readonly'] ), + 'destructiveHint' => ! empty( $annotations['destructive'] ), + 'idempotentHint' => ! empty( $annotations['idempotent'] ), + ), + ); + } + + /** + * Whether the current user may run an ability, per its own permission callback. + * + * @since x.x.x + * + * @param \WP_Ability $ability Ability. + * @return bool True when allowed. + */ + private function can_run( WP_Ability $ability ): bool { + return true === $ability->check_permissions(); + } + + /** + * Registered abilities keyed by name. + * + * @since x.x.x + * + * @return array Abilities. + */ + private function get_registered(): array { + $registered = array(); + foreach ( wp_get_abilities() as $ability ) { + if ( ! ( $ability instanceof WP_Ability ) ) { + continue; + } + + $registered[ $ability->get_name() ] = $ability; + } + + return $registered; + } + + /** + * Abilities that opt in through their meta. + * + * @since x.x.x + * + * @param string $context Page context. + * @return list Ability names. + */ + private function get_opted_in_names( string $context ): array { + $names = array(); + foreach ( $this->get_registered() as $ability ) { + $meta = $ability->get_meta(); + if ( ! self::meta_opts_in( $meta['webmcp'] ?? null, $context ) ) { + continue; + } + + $names[] = $ability->get_name(); + } + + return $names; + } + + /** + * Reads the `webmcp` meta value of an ability for one context. + * + * @since x.x.x + * + * @param mixed $value Meta value. + * @param string $context Page context. + * @return bool True when the value opts the ability into the context. + */ + public static function meta_opts_in( $value, string $context ): bool { + if ( true === $value ) { + return true; + } + if ( is_string( $value ) ) { + return $value === $context || 'all' === $value; + } + if ( is_array( $value ) ) { + return ! empty( $value[ $context ] ); + } + + return false; + } + + /** + * Abilities listed in the experiment's settings for a context. + * + * @since x.x.x + * + * @param string $context Page context. + * @return list Ability names. + */ + private function get_settings_names( string $context ): array { + $option = get_option( "wpai_feature_{$this->feature_id}_field_{$context}_abilities", '' ); + if ( ! is_string( $option ) || '' === trim( $option ) ) { + return array(); + } + + $names = array(); + $parts = preg_split( '/[\s,]+/', $option ); + if ( false === $parts ) { + return array(); + } + + foreach ( $parts as $name ) { + $name = trim( $name ); + if ( '' === $name ) { + continue; + } + + $names[] = $name; + } + + return $names; + } +} diff --git a/includes/Experiments/WebMCP/WebMCP.php b/includes/Experiments/WebMCP/WebMCP.php new file mode 100644 index 000000000..77d3bb292 --- /dev/null +++ b/includes/Experiments/WebMCP/WebMCP.php @@ -0,0 +1,196 @@ + __( 'WebMCP', 'ai' ), + 'description' => __( 'Exposes a curated set of WordPress abilities to agent browsers as WebMCP tools on the page, through document.modelContext. Nothing is exposed until an ability opts in, a filter allows it, or it is listed in the settings below.', 'ai' ), + 'category' => Experiment_Category::ADMIN, + 'capability' => 'none', + ); + } + + /** + * {@inheritDoc} + */ + public function get_settings_fields(): array { + return array( + array( + 'id' => 'admin_abilities', + 'label' => __( 'Abilities exposed in wp-admin (comma-separated ability names, for example core/get-site-info)', 'ai' ), + 'type' => 'string', + 'default' => '', + ), + array( + 'id' => 'visitor_abilities', + 'label' => __( 'Abilities exposed to visitors on the front end (comma-separated ability names; leave empty to load nothing on the front end)', 'ai' ), + 'type' => 'string', + 'default' => '', + ), + ); + } + + /** + * {@inheritDoc} + */ + public function register(): void { + $this->curator = new Tool_Curator( self::get_id() ); + $this->rest = new REST_Controller( $this->curator ); + + add_action( 'rest_api_init', array( $this->rest, 'register_routes' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_front_end_assets' ) ); + } + + /** + * Returns the curator, creating it when the experiment was not registered through the loader. + * + * @since x.x.x + * + * @return \WordPress\AI\Experiments\WebMCP\Tool_Curator Curator. + */ + public function get_curator(): Tool_Curator { + if ( null === $this->curator ) { + $this->curator = new Tool_Curator( self::get_id() ); + } + + return $this->curator; + } + + /** + * Loads the bridge on wp-admin screens for logged-in users. + * + * Every admin screen gets the script: the tool set is the same across + * wp-admin and the browser only registers tools when it implements WebMCP. + * + * @since x.x.x + * + * @param string $hook_suffix Current admin page hook suffix. + */ + public function enqueue_admin_assets( string $hook_suffix ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Signature of the admin_enqueue_scripts hook. + if ( ! is_user_logged_in() ) { + return; + } + + if ( ! $this->get_curator()->has_exposed_abilities( self::CONTEXT_ADMIN ) ) { + return; + } + + $this->enqueue_bridge( self::CONTEXT_ADMIN ); + } + + /** + * Loads the bridge on the front end, only when something is exposed to visitors. + * + * @since x.x.x + */ + public function enqueue_front_end_assets(): void { + if ( ! $this->get_curator()->has_exposed_abilities( self::CONTEXT_VISITOR ) ) { + return; + } + + $this->enqueue_bridge( self::CONTEXT_VISITOR ); + } + + /** + * Enqueues the bridge script with the data it needs to talk to the REST routes. + * + * @since x.x.x + * + * @param string $context Page context, one of the CONTEXT_* constants. + */ + private function enqueue_bridge( string $context ): void { + Asset_Loader::add_global_data( + 'WebMCP', + array( + 'context' => $context, + 'toolsUrl' => rest_url( REST_Controller::NAMESPACE . '/webmcp/tools' ), + 'executeUrl' => rest_url( REST_Controller::NAMESPACE . '/webmcp/execute' ), + 'nonceUrl' => rest_url( REST_Controller::NAMESPACE . '/webmcp/nonce' ), + 'restNonce' => wp_create_nonce( 'wp_rest' ), + 'nonce' => wp_create_nonce( REST_Controller::NONCE_ACTION ), + ) + ); + Asset_Loader::enqueue_script( self::SCRIPT_HANDLE, 'experiments/webmcp' ); + } +} diff --git a/src/experiments/webmcp/index.ts b/src/experiments/webmcp/index.ts new file mode 100644 index 000000000..83b9f8a6b --- /dev/null +++ b/src/experiments/webmcp/index.ts @@ -0,0 +1,235 @@ +/** + * WebMCP bridge. + * + * Registers the tools the server exposes for this page on + * `document.modelContext`, one `registerTool` call per tool, and executes + * them through the experiment's REST route. No WordPress packages are + * imported on purpose: the script also loads on the front end. + * + * Findings this follows, from running a WordPress bridge against ChatGPT's + * in-app browser: `modelContext` lives on `document` (with `navigator` as a + * fallback for older builds) and is a frozen object that implements only + * `registerTool`; batch `provideContext` throws. Tools are registered one at + * a time and each failure is swallowed so one bad schema does not take the + * rest down. + */ + +interface BridgeData { + context: string; + toolsUrl: string; + executeUrl: string; + nonceUrl: string; + restNonce: string; + nonce: string; +} + +interface ToolDefinition { + name: string; + description: string; + inputSchema: Record< string, unknown >; + annotations?: Record< string, unknown >; +} + +interface RegisteredTool extends ToolDefinition { + execute: ( input: unknown ) => Promise< ToolResult >; +} + +interface ToolResult { + content: Array< { type: 'text'; text: string } >; +} + +interface ModelContext { + registerTool?: ( tool: RegisteredTool ) => unknown; + provideContext?: ( context: { tools: RegisteredTool[] } ) => unknown; +} + +declare global { + interface Window { + aiWebMCP?: BridgeData; + } + interface Document { + modelContext?: ModelContext; + } + interface Navigator { + modelContext?: ModelContext; + } +} + +const getModelContext = (): ModelContext | null => { + if ( typeof document !== 'undefined' && document.modelContext ) { + return document.modelContext; + } + if ( typeof navigator !== 'undefined' && navigator.modelContext ) { + return navigator.modelContext; + } + return null; +}; + +const toTextResult = ( value: unknown ): ToolResult => ( { + content: [ + { + type: 'text', + text: typeof value === 'string' ? value : JSON.stringify( value ), + }, + ], +} ); + +const bridge = ( data: BridgeData, modelContext: ModelContext ) => { + let { nonce, restNonce } = data; + + const headers = ( withToken: boolean ): Record< string, string > => { + const result: Record< string, string > = { + 'Content-Type': 'application/json', + }; + if ( restNonce ) { + // The cookie session's own nonce. Core rejects anything else in + // this header, which is why the experiment's token has its own. + result[ 'X-WP-Nonce' ] = restNonce; + } + if ( withToken && nonce ) { + result[ 'X-WPAI-WebMCP-Nonce' ] = nonce; + } + return result; + }; + + const refreshNonces = async (): Promise< void > => { + try { + const response = await fetch( data.nonceUrl, { + credentials: 'same-origin', + headers: headers( false ), + } ); + if ( ! response.ok ) { + return; + } + const json = await response.json(); + if ( typeof json?.nonce === 'string' ) { + nonce = json.nonce; + } + if ( typeof json?.restNonce === 'string' ) { + restNonce = json.restNonce; + } + } catch { + // A failed refresh surfaces on the next execution as a 403. + } + }; + + const loadTools = async (): Promise< ToolDefinition[] > => { + const url = new URL( data.toolsUrl, window.location.href ); + url.searchParams.set( 'context', data.context ); + const response = await fetch( url.toString(), { + credentials: 'same-origin', + headers: headers( false ), + } ); + if ( ! response.ok ) { + return []; + } + const json = await response.json(); + if ( typeof json?.nonce === 'string' ) { + nonce = json.nonce; + } + if ( typeof json?.restNonce === 'string' ) { + restNonce = json.restNonce; + } + return Array.isArray( json?.tools ) ? json.tools : []; + }; + + const post = ( body: string ): Promise< Response > => + fetch( data.executeUrl, { + method: 'POST', + credentials: 'same-origin', + headers: headers( true ), + body, + } ); + + const runTool = async ( + name: string, + input: unknown + ): Promise< ToolResult > => { + const body = JSON.stringify( { + tool: name, + context: data.context, + input: input && typeof input === 'object' ? input : {}, + } ); + + let response = await post( body ); + if ( response.status === 403 ) { + // Tokens expire while a page stays open. Refresh once, then retry. + await refreshNonces(); + response = await post( body ); + } + + let json: { + result?: unknown; + message?: string; + code?: string; + } = {}; + try { + json = await response.json(); + } catch { + // A non-JSON body is reported through the status below. + } + + if ( ! response.ok ) { + throw new Error( + json.message ?? `WebMCP: HTTP ${ response.status }` + ); + } + + return toTextResult( json.result ); + }; + + const registerTools = ( tools: ToolDefinition[] ) => { + const registered = tools + .filter( ( tool ) => tool && tool.name && tool.description ) + .map( + ( tool ): RegisteredTool => ( { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema ?? { type: 'object' }, + ...( tool.annotations + ? { annotations: tool.annotations } + : {} ), + execute: ( input: unknown ) => runTool( tool.name, input ), + } ) + ); + + if ( registered.length === 0 ) { + return; + } + + if ( typeof modelContext.registerTool === 'function' ) { + for ( const tool of registered ) { + try { + const result = modelContext.registerTool( tool ) as + | { catch?: ( handler: () => void ) => unknown } + | undefined; + result?.catch?.( () => {} ); + } catch { + // One bad tool must not stop the others. + } + } + return; + } + + if ( typeof modelContext.provideContext === 'function' ) { + try { + modelContext.provideContext( { tools: registered } ); + } catch { + // Older polyfills only; nothing to do when this throws. + } + } + }; + + loadTools() + .then( registerTools ) + .catch( () => {} ); +}; + +const data = window.aiWebMCP; +const modelContext = getModelContext(); + +if ( data && modelContext ) { + bridge( data, modelContext ); +} + +export {}; diff --git a/tests/Integration/Includes/Experiments/WebMCP/REST_ControllerTest.php b/tests/Integration/Includes/Experiments/WebMCP/REST_ControllerTest.php new file mode 100644 index 000000000..8bcdb5a23 --- /dev/null +++ b/tests/Integration/Includes/Experiments/WebMCP/REST_ControllerTest.php @@ -0,0 +1,224 @@ + + */ + private array $registered = array(); + + /** + * Registers the routes and two abilities: one exposed to admins, one to visitors. + */ + public function setUp(): void { + parent::setUp(); + + $controller = new REST_Controller( new Tool_Curator( 'webmcp' ) ); + add_action( 'rest_api_init', array( $controller, 'register_routes' ) ); + do_action( 'rest_api_init', rest_get_server() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook fired to register the routes under test. + + $this->register_ability( + 'webmcp-test/echo', + array( + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( 'text' => array( 'type' => 'string' ) ), + ), + 'execute_callback' => static function ( $input ) { + return array( 'echo' => $input['text'] ?? '' ); + }, + 'meta' => array( 'webmcp' => 'admin' ), + ) + ); + $this->register_ability( 'webmcp-test/public', array( 'meta' => array( 'webmcp' => 'visitor' ) ) ); + } + + /** + * Cleans up. + */ + public function tearDown(): void { + foreach ( $this->registered as $name ) { + wp_unregister_ability( $name ); + } + $this->registered = array(); + wp_set_current_user( 0 ); + remove_all_actions( 'rest_api_init' ); + parent::tearDown(); + } + + /** + * Registers a test ability inside the abilities init context. + * + * @param string $name Ability name. + * @param array $args Overrides. + */ + private function register_ability( string $name, array $args = array() ): void { + global $wp_current_filter; + + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register a test ability. + + try { + wp_register_ability( + $name, + array_merge( + array( + 'label' => 'Test ' . $name, + 'description' => 'Test ability ' . $name, + 'category' => WPAI_DEFAULT_ABILITY_CATEGORY, + 'execute_callback' => static function () use ( $name ) { + return array( 'ran' => $name ); + }, + 'permission_callback' => '__return_true', + ), + $args + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + + $this->registered[] = $name; + } + + /** + * Tests that the tools route filters by context and returns tokens. + */ + public function test_tools_route_lists_per_context(): void { + $request = new WP_REST_Request( 'GET', '/ai/v1/webmcp/tools' ); + $request->set_param( 'context', WebMCP::CONTEXT_ADMIN ); + $data = rest_get_server()->dispatch( $request )->get_data(); + + $this->assertSame( WebMCP::CONTEXT_ADMIN, $data['context'] ); + $this->assertSame( array( 'webmcp-test__echo' ), array_column( $data['tools'], 'name' ) ); + $this->assertSame( 0, $data['truncated'] ); + $this->assertSame( 30, $data['maxTools'] ); + $this->assertNotEmpty( $data['nonce'] ); + $this->assertNotEmpty( $data['restNonce'] ); + + $request = new WP_REST_Request( 'GET', '/ai/v1/webmcp/tools' ); + $request->set_param( 'context', WebMCP::CONTEXT_VISITOR ); + $data = rest_get_server()->dispatch( $request )->get_data(); + + $this->assertSame( array( 'webmcp-test__public' ), array_column( $data['tools'], 'name' ) ); + } + + /** + * Tests that an unknown context is rejected by the schema. + */ + public function test_tools_route_rejects_unknown_context(): void { + $request = new WP_REST_Request( 'GET', '/ai/v1/webmcp/tools' ); + $request->set_param( 'context', 'editor' ); + + $this->assertSame( 400, rest_get_server()->dispatch( $request )->get_status() ); + } + + /** + * Tests that execution without the experiment's token is refused. + */ + public function test_execute_requires_the_experiment_token(): void { + $request = new WP_REST_Request( 'POST', '/ai/v1/webmcp/execute' ); + $request->set_body_params( array( 'tool' => 'webmcp-test__echo' ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertSame( 'wpai_webmcp_invalid_nonce', $response->as_error()->get_error_code() ); + } + + /** + * Tests a full execution: name mapping, input, result. + */ + public function test_execute_runs_an_exposed_ability(): void { + $request = new WP_REST_Request( 'POST', '/ai/v1/webmcp/execute' ); + $request->set_header( REST_Controller::NONCE_HEADER, wp_create_nonce( REST_Controller::NONCE_ACTION ) ); + $request->set_body_params( + array( + 'tool' => 'webmcp-test__echo', + 'context' => WebMCP::CONTEXT_ADMIN, + 'input' => array( 'text' => 'hello' ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'webmcp-test/echo', $response->get_data()['ability'] ); + $this->assertSame( array( 'echo' => 'hello' ), $response->get_data()['result'] ); + } + + /** + * Tests that a tool not exposed in the requested context cannot be run through the route. + */ + public function test_execute_refuses_a_tool_outside_its_context(): void { + $request = new WP_REST_Request( 'POST', '/ai/v1/webmcp/execute' ); + $request->set_header( REST_Controller::NONCE_HEADER, wp_create_nonce( REST_Controller::NONCE_ACTION ) ); + $request->set_body_params( + array( + 'tool' => 'webmcp-test__echo', + 'context' => WebMCP::CONTEXT_VISITOR, + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 404, $response->get_status() ); + $this->assertSame( 'wpai_webmcp_tool_not_exposed', $response->as_error()->get_error_code() ); + } + + /** + * Tests that the ability's own permission callback still decides. + */ + public function test_execute_returns_the_permission_error_of_the_ability(): void { + $this->register_ability( + 'webmcp-test/locked', + array( + 'meta' => array( 'webmcp' => 'admin' ), + 'permission_callback' => '__return_false', + ) + ); + + $request = new WP_REST_Request( 'POST', '/ai/v1/webmcp/execute' ); + $request->set_header( REST_Controller::NONCE_HEADER, wp_create_nonce( REST_Controller::NONCE_ACTION ) ); + $request->set_body_params( + array( + 'tool' => 'webmcp-test__locked', + 'context' => WebMCP::CONTEXT_ADMIN, + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertTrue( $response->is_error() ); + $this->assertSame( 403, $response->get_status() ); + } + + /** + * Tests the nonce route. + */ + public function test_nonce_route_issues_both_tokens(): void { + $data = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/ai/v1/webmcp/nonce' ) )->get_data(); + + $this->assertSame( 1, wp_verify_nonce( $data['nonce'], REST_Controller::NONCE_ACTION ) ); + $this->assertSame( 1, wp_verify_nonce( $data['restNonce'], 'wp_rest' ) ); + } +} diff --git a/tests/Integration/Includes/Experiments/WebMCP/Tool_CuratorTest.php b/tests/Integration/Includes/Experiments/WebMCP/Tool_CuratorTest.php new file mode 100644 index 000000000..8f1b09810 --- /dev/null +++ b/tests/Integration/Includes/Experiments/WebMCP/Tool_CuratorTest.php @@ -0,0 +1,251 @@ + + */ + private array $registered = array(); + + /** + * Curator under test. + * + * @var \WordPress\AI\Experiments\WebMCP\Tool_Curator + */ + private Tool_Curator $curator; + + /** + * Sets up the curator. + */ + public function setUp(): void { + parent::setUp(); + $this->curator = new Tool_Curator( 'webmcp' ); + } + + /** + * Cleans up. + */ + public function tearDown(): void { + foreach ( $this->registered as $name ) { + wp_unregister_ability( $name ); + } + $this->registered = array(); + wp_set_current_user( 0 ); + delete_option( 'wpai_feature_webmcp_field_admin_abilities' ); + delete_option( 'wpai_feature_webmcp_field_visitor_abilities' ); + remove_all_filters( 'wpai_webmcp_exposed_abilities' ); + remove_all_filters( 'wpai_webmcp_max_tools' ); + parent::tearDown(); + } + + /** + * Registers a test ability inside the abilities init context. + * + * @param string $name Ability name. + * @param array $args Overrides. + */ + private function register_ability( string $name, array $args = array() ): void { + global $wp_current_filter; + + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register a test ability. + + try { + wp_register_ability( + $name, + array_merge( + array( + 'label' => 'Test ' . $name, + 'description' => 'Test ability ' . $name, + 'category' => WPAI_DEFAULT_ABILITY_CATEGORY, + 'execute_callback' => static function () use ( $name ) { + return array( 'ran' => $name ); + }, + 'permission_callback' => '__return_true', + ), + $args + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + + $this->registered[] = $name; + } + + /** + * Tests that nothing is exposed without an opt-in. + */ + public function test_nothing_is_exposed_by_default(): void { + $this->register_ability( 'webmcp-test/plain' ); + + $this->assertSame( array(), $this->curator->get_exposed_names( WebMCP::CONTEXT_ADMIN ) ); + $this->assertSame( array(), $this->curator->get_exposed_names( WebMCP::CONTEXT_VISITOR ) ); + $this->assertFalse( $this->curator->has_exposed_abilities( WebMCP::CONTEXT_ADMIN ) ); + } + + /** + * Tests the three meta opt-in shapes. + */ + public function test_meta_opt_in_shapes(): void { + $this->register_ability( 'webmcp-test/everywhere', array( 'meta' => array( 'webmcp' => true ) ) ); + $this->register_ability( 'webmcp-test/admin-only', array( 'meta' => array( 'webmcp' => 'admin' ) ) ); + $this->register_ability( 'webmcp-test/visitor-only', array( 'meta' => array( 'webmcp' => array( 'visitor' => true ) ) ) ); + + $this->assertSame( + array( 'webmcp-test/admin-only', 'webmcp-test/everywhere' ), + $this->curator->get_exposed_names( WebMCP::CONTEXT_ADMIN ) + ); + $this->assertSame( + array( 'webmcp-test/everywhere', 'webmcp-test/visitor-only' ), + $this->curator->get_exposed_names( WebMCP::CONTEXT_VISITOR ) + ); + } + + /** + * Tests the settings field and the filter, and that unknown names are dropped. + */ + public function test_settings_and_filter_expose_abilities(): void { + $this->register_ability( 'webmcp-test/from-settings' ); + $this->register_ability( 'webmcp-test/from-filter' ); + + update_option( 'wpai_feature_webmcp_field_admin_abilities', "webmcp-test/from-settings, does-not/exist\n" ); + add_filter( + 'wpai_webmcp_exposed_abilities', + static function ( array $names, string $context ) { + if ( WebMCP::CONTEXT_ADMIN === $context ) { + $names[] = 'webmcp-test/from-filter'; + } + return $names; + }, + 10, + 2 + ); + + $this->assertSame( + array( 'webmcp-test/from-filter', 'webmcp-test/from-settings' ), + $this->curator->get_exposed_names( WebMCP::CONTEXT_ADMIN ) + ); + $this->assertSame( array(), $this->curator->get_exposed_names( WebMCP::CONTEXT_VISITOR ) ); + $this->assertTrue( $this->curator->is_exposed( 'webmcp-test/from-filter', WebMCP::CONTEXT_ADMIN ) ); + $this->assertFalse( $this->curator->is_exposed( 'webmcp-test/from-filter', WebMCP::CONTEXT_VISITOR ) ); + } + + /** + * Tests the wire name mapping in both directions. + */ + public function test_tool_name_separator_round_trips(): void { + $this->assertSame( 'core__get-post', Tool_Curator::to_tool_name( 'core/get-post' ) ); + $this->assertSame( 'core/get-post', Tool_Curator::to_ability_name( 'core__get-post' ) ); + $this->assertSame( 'my-plugin/nested-name', Tool_Curator::to_ability_name( Tool_Curator::to_tool_name( 'my-plugin/nested-name' ) ) ); + } + + /** + * Tests the tool shape: name, description, an object schema, and annotations from the ability. + */ + public function test_convert_produces_webmcp_tool_shape(): void { + $this->register_ability( + 'webmcp-test/shape', + array( + 'label' => 'Shape', + 'description' => 'Returns a shape.', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( 'id' => array( 'type' => 'integer' ) ), + ), + 'meta' => array( + 'webmcp' => true, + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + ), + ) + ); + + $tool = $this->curator->convert( wp_get_ability( 'webmcp-test/shape' ) ); + + $this->assertSame( 'webmcp-test__shape', $tool['name'] ); + $this->assertSame( 'Shape. Returns a shape.', $tool['description'] ); + $this->assertSame( 'object', $tool['inputSchema']['type'] ); + $this->assertArrayHasKey( 'id', $tool['inputSchema']['properties'] ); + $this->assertTrue( $tool['annotations']['readOnlyHint'] ); + $this->assertFalse( $tool['annotations']['destructiveHint'] ); + $this->assertTrue( $tool['annotations']['idempotentHint'] ); + } + + /** + * Tests that an ability without an input schema gets an empty object schema, encoded as `{}` not `[]`. + */ + public function test_convert_gives_schemaless_ability_an_object_schema(): void { + $this->register_ability( 'webmcp-test/no-schema', array( 'meta' => array( 'webmcp' => true ) ) ); + + $tool = $this->curator->convert( wp_get_ability( 'webmcp-test/no-schema' ) ); + + $this->assertSame( 'object', $tool['inputSchema']['type'] ); + $this->assertStringContainsString( '"properties":{}', wp_json_encode( $tool['inputSchema'] ) ); + } + + /** + * Tests the per-page cap and the truncated count. + */ + public function test_get_tools_respects_the_cap(): void { + $this->register_ability( 'webmcp-test/one', array( 'meta' => array( 'webmcp' => true ) ) ); + $this->register_ability( 'webmcp-test/two', array( 'meta' => array( 'webmcp' => true ) ) ); + $this->register_ability( 'webmcp-test/three', array( 'meta' => array( 'webmcp' => true ) ) ); + + add_filter( 'wpai_webmcp_max_tools', static fn() => 2 ); + + $result = $this->curator->get_tools( WebMCP::CONTEXT_ADMIN ); + + $this->assertCount( 2, $result['tools'] ); + $this->assertSame( 1, $result['truncated'] ); + $this->assertSame( 2, $this->curator->get_max_tools() ); + } + + /** + * Tests that an ability the current user may not run is not listed. + */ + public function test_get_tools_hides_abilities_the_user_may_not_run(): void { + $this->register_ability( 'webmcp-test/allowed', array( 'meta' => array( 'webmcp' => true ) ) ); + $this->register_ability( + 'webmcp-test/forbidden', + array( + 'meta' => array( 'webmcp' => true ), + 'permission_callback' => '__return_false', + ) + ); + + $names = array_column( $this->curator->get_tools( WebMCP::CONTEXT_ADMIN )['tools'], 'name' ); + + $this->assertSame( array( 'webmcp-test__allowed' ), $names ); + } + + /** + * Tests that an unknown context exposes nothing. + */ + public function test_unknown_context_exposes_nothing(): void { + $this->register_ability( 'webmcp-test/everywhere', array( 'meta' => array( 'webmcp' => true ) ) ); + + $this->assertFalse( Tool_Curator::is_valid_context( 'editor' ) ); + $this->assertSame( array(), $this->curator->get_exposed_names( 'editor' ) ); + } +} diff --git a/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php b/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php new file mode 100644 index 000000000..cf6244c74 --- /dev/null +++ b/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php @@ -0,0 +1,133 @@ +assertSame( 'webmcp', $experiment->get_id() ); + $this->assertSame( 'WebMCP', $experiment->get_label() ); + $this->assertSame( Experiment_Category::ADMIN, $experiment->get_category() ); + $this->assertSame( 'experimental', $experiment->get_stability() ); + $this->assertSame( 'none', $experiment->get_capability() ); + } + + /** + * Tests that the experiment is off unless its option is set. + */ + public function test_experiment_is_disabled_by_default(): void { + delete_option( 'wpai_feature_webmcp_enabled' ); + + $this->assertFalse( ( new WebMCP() )->is_enabled() ); + } + + /** + * Tests that the experiment is on when its option is set. + */ + public function test_experiment_is_enabled_when_option_set(): void { + $this->assertTrue( ( new WebMCP() )->is_enabled() ); + } + + /** + * Tests the two settings fields. + */ + public function test_settings_fields(): void { + $ids = array_column( ( new WebMCP() )->get_settings_fields(), 'id' ); + + $this->assertSame( array( 'admin_abilities', 'visitor_abilities' ), $ids ); + $this->assertSame( 'wpai_feature_webmcp_field_admin_abilities', WebMCP::get_field_option_name( 'admin_abilities' ) ); + } + + /** + * Tests registration through the plugin's loader and the routes it adds. + */ + public function test_experiment_registers_through_loader(): void { + $registry = new Registry(); + $loader = new Loader( $registry ); + + add_action( + 'wpai_register_features', + static function ( $reg ) { + $reg->register_feature( new WebMCP() ); + } + ); + + $loader->init(); + do_action( 'rest_api_init', rest_get_server() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook fired to register the routes under test. + + $this->assertInstanceOf( WebMCP::class, $registry->get_feature( 'webmcp' ) ); + + $routes = rest_get_server()->get_routes( 'ai/v1' ); + $this->assertArrayHasKey( '/ai/v1/webmcp/tools', $routes ); + $this->assertArrayHasKey( '/ai/v1/webmcp/execute', $routes ); + $this->assertArrayHasKey( '/ai/v1/webmcp/nonce', $routes ); + } + + /** + * Tests that the bridge is not enqueued for a logged-out request to wp-admin. + */ + public function test_bridge_not_enqueued_for_logged_out_admin(): void { + wp_set_current_user( 0 ); + update_option( 'wpai_feature_webmcp_field_admin_abilities', 'core/get-post' ); + + $experiment = new WebMCP(); + $experiment->register(); + $experiment->enqueue_admin_assets( 'index.php' ); + + $this->assertFalse( wp_script_is( 'ai-webmcp', 'enqueued' ) ); + } + + /** + * Tests that the bridge is not enqueued on the front end when nothing is exposed to visitors. + */ + public function test_bridge_not_enqueued_on_front_end_without_visitor_abilities(): void { + $experiment = new WebMCP(); + $experiment->register(); + $experiment->enqueue_front_end_assets(); + + $this->assertFalse( wp_script_is( 'ai-webmcp', 'enqueued' ) ); + } +} diff --git a/webpack.config.js b/webpack.config.js index 35db526e5..a889fc20f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -100,6 +100,11 @@ module.exports = { 'src/experiments/slug-generation', 'index.tsx' ), + 'experiments/webmcp': path.resolve( + process.cwd(), + 'src/experiments/webmcp', + 'index.ts' + ), 'experiments/type-ahead': path.resolve( process.cwd(), 'src/experiments/type-ahead', From a07242ecc89c943db6fcc1c245761c57086cb314 Mon Sep 17 00:00:00 2001 From: Mihai Dragomirescu Date: Wed, 30 Sep 2026 19:55:08 +0300 Subject: [PATCH 2/8] WebMCP: satisfy static analysis, and test the enqueue paths phpstan-wordpress infers the filter's return type from its default and WP_Ability::get_input_schema() is typed array, so the two is_array() guards read as always true; one is dropped, the other kept with an ignore, because a filter callback can return anything at runtime. The enqueue tests now assert on the real handle (the loader prefixes ai_, not ai-), register their own ability because core's are absent in the PHPUnit context, and cover the positive paths on wp-admin and the front end, which CI can run because it builds the scripts first. Co-Authored-By: Claude Fable 5.1 --- includes/Experiments/WebMCP/Tool_Curator.php | 4 +- .../Experiments/WebMCP/WebMCPTest.php | 99 ++++++++++++++++++- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/includes/Experiments/WebMCP/Tool_Curator.php b/includes/Experiments/WebMCP/Tool_Curator.php index 10bdd1582..8978ec27a 100644 --- a/includes/Experiments/WebMCP/Tool_Curator.php +++ b/includes/Experiments/WebMCP/Tool_Curator.php @@ -173,7 +173,7 @@ public function get_exposed_names( string $context ): array { */ $names = apply_filters( 'wpai_webmcp_exposed_abilities', $names, $context ); - if ( ! is_array( $names ) ) { + if ( ! is_array( $names ) ) { // @phpstan-ignore function.alreadyNarrowedType (the type is inferred from the default; a filter callback can return anything at runtime) return array(); } @@ -275,7 +275,7 @@ public function convert( WP_Ability $ability ): array { } $input_schema = $ability->get_input_schema(); - if ( ! is_array( $input_schema ) || array() === $input_schema ) { + if ( array() === $input_schema ) { $input_schema = array( 'type' => 'object' ); } if ( ( $input_schema['type'] ?? null ) === 'object' && empty( $input_schema['properties'] ) ) { diff --git a/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php b/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php index cf6244c74..5613d23e3 100644 --- a/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php +++ b/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php @@ -28,20 +28,63 @@ public function setUp(): void { update_option( 'wpai_feature_webmcp_enabled', true ); } + /** + * Abilities registered by a test, unregistered on teardown. + * + * @var list + */ + private array $registered = array(); + /** * Cleans up. */ public function tearDown(): void { + foreach ( $this->registered as $name ) { + wp_unregister_ability( $name ); + } + $this->registered = array(); wp_set_current_user( 0 ); delete_option( 'wpai_feature_webmcp_enabled' ); delete_option( 'wpai_feature_webmcp_field_admin_abilities' ); delete_option( 'wpai_feature_webmcp_field_visitor_abilities' ); remove_all_filters( 'wpai_webmcp_exposed_abilities' ); remove_all_actions( 'wpai_register_features' ); - wp_dequeue_script( 'ai-webmcp' ); + wp_dequeue_script( 'ai_webmcp' ); + wp_deregister_script( 'ai_webmcp' ); parent::tearDown(); } + /** + * Registers a read-only test ability inside the abilities init context. + * + * Core's own abilities are not registered in the PHPUnit context, so the + * enqueue tests bring their own. + * + * @param string $name Ability name. + */ + private function register_ability( string $name ): void { + global $wp_current_filter; + + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register a test ability. + + try { + wp_register_ability( + $name, + array( + 'label' => 'Test ' . $name, + 'description' => 'Test ability ' . $name, + 'category' => WPAI_DEFAULT_ABILITY_CATEGORY, + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + + $this->registered[] = $name; + } + /** * Tests the metadata. */ @@ -111,13 +154,61 @@ static function ( $reg ) { */ public function test_bridge_not_enqueued_for_logged_out_admin(): void { wp_set_current_user( 0 ); - update_option( 'wpai_feature_webmcp_field_admin_abilities', 'core/get-post' ); + $this->register_ability( 'webmcp-test/admin-tool' ); + update_option( 'wpai_feature_webmcp_field_admin_abilities', 'webmcp-test/admin-tool' ); + + $experiment = new WebMCP(); + $experiment->register(); + $experiment->enqueue_admin_assets( 'index.php' ); + + $this->assertFalse( wp_script_is( 'ai_webmcp', 'enqueued' ) ); + } + + /** + * Tests that a logged-in user on a wp-admin screen gets the bridge and its data when something is exposed. + * + * CI builds the scripts before the PHP tests run, so the asset file exists here. + */ + public function test_bridge_enqueued_for_logged_in_admin_with_exposed_ability(): void { + if ( ! file_exists( WPAI_PLUGIN_DIR . 'build-scripts/experiments/webmcp.asset.php' ) ) { + $this->markTestSkipped( 'Scripts are not built.' ); + } + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $this->register_ability( 'webmcp-test/admin-tool' ); + update_option( 'wpai_feature_webmcp_field_admin_abilities', 'webmcp-test/admin-tool' ); $experiment = new WebMCP(); $experiment->register(); $experiment->enqueue_admin_assets( 'index.php' ); - $this->assertFalse( wp_script_is( 'ai-webmcp', 'enqueued' ) ); + $this->assertTrue( wp_script_is( 'ai_webmcp', 'enqueued' ) ); + + $inline = wp_scripts()->get_data( 'ai_webmcp', 'before' ); + $this->assertIsArray( $inline ); + // wp_json_encode() escapes slashes, so compare on the unescaped text. + $printed = str_replace( '\\/', '/', implode( "\n", array_filter( $inline, 'is_string' ) ) ); + $this->assertStringContainsString( 'window.aiWebMCP=', $printed ); + $this->assertStringContainsString( '"context":"admin"', $printed ); + $this->assertStringContainsString( '/ai/v1/webmcp/execute', $printed ); + } + + /** + * Tests that the front end gets the bridge once a visitor ability is listed. + */ + public function test_bridge_enqueued_on_front_end_with_visitor_ability(): void { + if ( ! file_exists( WPAI_PLUGIN_DIR . 'build-scripts/experiments/webmcp.asset.php' ) ) { + $this->markTestSkipped( 'Scripts are not built.' ); + } + + $this->register_ability( 'webmcp-test/visitor-tool' ); + update_option( 'wpai_feature_webmcp_field_visitor_abilities', 'webmcp-test/visitor-tool' ); + + $experiment = new WebMCP(); + $experiment->register(); + $experiment->enqueue_front_end_assets(); + + $this->assertTrue( wp_script_is( 'ai_webmcp', 'enqueued' ) ); } /** @@ -128,6 +219,6 @@ public function test_bridge_not_enqueued_on_front_end_without_visitor_abilities( $experiment->register(); $experiment->enqueue_front_end_assets(); - $this->assertFalse( wp_script_is( 'ai-webmcp', 'enqueued' ) ); + $this->assertFalse( wp_script_is( 'ai_webmcp', 'enqueued' ) ); } } From b1c05fd98b4d50fadbf4aabcd233e44af60804cf Mon Sep 17 00:00:00 2001 From: Mihai Dragomirescu Date: Wed, 30 Sep 2026 20:14:23 +0300 Subject: [PATCH 3/8] WebMCP: drive the block editor on the page, not the Abilities API The maintainers' reading of WebMCP is that an agent drives the UI on the current page and the person watches it happen, and that a site already connected over MCP gains nothing from the same server-side abilities registered again in the browser (#448). This commit changes what the experiment is accordingly. The Abilities API path is gone: no REST routes, no allowlists, no tokens. What stays is what is the same either way: registration on document.modelContext one tool at a time, the per-page cap, and a screen filter. The bridge now carries a JavaScript registry (wpai.webmcp.registerTool, the wpai.webmcp.tools filter) and ships nine editor tools that dispatch into core/editor and core/block-editor: read the document outline, set the title, insert a block, replace a block's text, change attributes, remove, select, save, publish. Every change is visible immediately and lands in the post's undo history. An e2e spec installs a document.modelContext shim before the editor loads, calls the tools the way a browser would, and asserts that the title field and the canvas change. An eval set with a small runner judges the tool descriptions by whether a model picks the right tool. Co-Authored-By: Claude Fable 5.1 --- docs/experiments/webmcp.md | 114 +++-- .../Experiments/WebMCP/REST_Controller.php | 262 ----------- includes/Experiments/WebMCP/Tool_Curator.php | 409 ------------------ includes/Experiments/WebMCP/WebMCP.php | 163 +++---- .../webmcp/editor-tool-definitions.mjs | 141 ++++++ src/experiments/webmcp/editor-tools.ts | 280 ++++++++++++ src/experiments/webmcp/evals.json | 15 + src/experiments/webmcp/index.ts | 296 +++++-------- src/experiments/webmcp/types.ts | 30 ++ .../WebMCP/REST_ControllerTest.php | 224 ---------- .../Experiments/WebMCP/Tool_CuratorTest.php | 251 ----------- .../Experiments/WebMCP/WebMCPTest.php | 160 +++---- tests/e2e/specs/experiments/webmcp.spec.js | 124 ++++++ tools/webmcp-evals.mjs | 97 +++++ 14 files changed, 957 insertions(+), 1609 deletions(-) delete mode 100644 includes/Experiments/WebMCP/REST_Controller.php delete mode 100644 includes/Experiments/WebMCP/Tool_Curator.php create mode 100644 src/experiments/webmcp/editor-tool-definitions.mjs create mode 100644 src/experiments/webmcp/editor-tools.ts create mode 100644 src/experiments/webmcp/evals.json create mode 100644 src/experiments/webmcp/types.ts delete mode 100644 tests/Integration/Includes/Experiments/WebMCP/REST_ControllerTest.php delete mode 100644 tests/Integration/Includes/Experiments/WebMCP/Tool_CuratorTest.php create mode 100644 tests/e2e/specs/experiments/webmcp.spec.js create mode 100644 tools/webmcp-evals.mjs diff --git a/docs/experiments/webmcp.md b/docs/experiments/webmcp.md index 611ec103a..cac6ded9d 100644 --- a/docs/experiments/webmcp.md +++ b/docs/experiments/webmcp.md @@ -2,93 +2,77 @@ ## Summary -The WebMCP experiment registers a curated set of WordPress abilities as WebMCP tools on the page, so an agent browser (ChatGPT's in-app browser, Chrome builds with WebMCP) can call them through `document.modelContext`. The Abilities API stays the single registry: the experiment decides which abilities a page exposes, hands them to the browser one `registerTool` call at a time, and executes them through a REST route that runs the ability's own permission and input checks on the server. +The WebMCP experiment lets an agent browser (ChatGPT's in-app browser, Chrome builds with WebMCP behind a flag) work in the block editor. On the post editor screens it registers a small set of tools on `document.modelContext`, and every tool acts on the page the person is looking at, through the editor's own data stores: the title changes, the block appears, the post saves, in front of them, and they can stop at any point. -Nothing is exposed until an ability opts in, a filter allows it, or the site owner lists it in the experiment's settings. +This is deliberately not a second door to the Abilities API. A site that wants server-side abilities in an agent connects it over MCP. WebMCP is for the page. ## Overview -When enabled, the experiment does three things. +When enabled, on `post.php` and `post-new.php` the experiment enqueues a bridge script. The bridge: -1. On wp-admin screens (for logged-in users) and on the front end (only when something is exposed to visitors) it enqueues a small bridge script with the REST URLs and two tokens. -2. The bridge asks `GET /wp-json/ai/v1/webmcp/tools?context=admin|visitor` for the tools this page exposes and registers each one with `document.modelContext.registerTool()`. -3. When the agent calls a tool, the bridge posts to `POST /wp-json/ai/v1/webmcp/execute` and returns the ability's result as text content. +1. collects tools from its registry (the built-in editor tools, plus anything a plugin adds), +2. runs them through the `wpai.webmcp.tools` filter, +3. registers each one with `document.modelContext.registerTool()`, one call per tool, up to the per-page cap. -The browser only ever sees names, descriptions and input schemas. Permission callbacks, input validation and execution run in WordPress through `WP_Ability::execute()`. +Each tool's `execute` runs in the page and dispatches into `core/editor` and `core/block-editor`, the same stores the editor's own UI uses, so the result is visible immediately and lands in the post's undo history. -## Contexts +## Editor tools -Agent browsers cap the number of tools a page may register. In testing, a few hundred tools disabled WebMCP for the document with no error, while about thirty worked. A logged-in editor and a visitor also need different tools, so the experiment keeps two allowlists: +| Tool | What the person sees | +| --- | --- | +| `editor-get-document` | Nothing changes. Returns the post ID, type, status, title, and an outline of the blocks with their `clientId`, block name and a short text preview, so the agent can refer to a block precisely. | +| `editor-set-title` | The title field updates. | +| `editor-insert-block` | A new block appears, selected. Defaults to a paragraph; takes a block name, attributes, and an optional `afterClientId` to place it after a specific block. | +| `editor-update-block-text` | The text of a paragraph, heading, list item, quote or similar block is replaced; the block is selected. | +| `editor-update-block-attributes` | Any attributes of a block change; the block is selected. | +| `editor-remove-block` | The block disappears. | +| `editor-select-block` | The block is highlighted, for the agent to point at something before asking. | +| `editor-save` | The post saves (draft stays draft). | +| `editor-publish` | The post's status changes to published and it saves. Annotated as not read-only so an agent asks first. | -- `admin`: wp-admin screens, logged-in users. -- `visitor`: the front end. The script is not loaded there unless the visitor list is non-empty. +Tool descriptions are written for the model, in English, and are not translated. -The context is decided by the surface, not the login: a logged-in user reading the front end gets the visitor set. +## Adding tools from a plugin or another screen -The cap defaults to 30 tools and can be changed with the `wpai_webmcp_max_tools` filter. Tools beyond the cap are left out, and the tools response reports how many in `truncated`. +The bridge exposes a registry on `window.wpai.webmcp`: -## Exposing an ability +```js +wpai.webmcp.registerTool( { + name: 'woo-add-to-cart', + description: 'Adds the product on the current page to the cart. The cart count updates on the page.', + inputSchema: { type: 'object', properties: { quantity: { type: 'integer' } } }, + annotations: { readOnlyHint: false }, + execute: async ( { quantity = 1 } ) => { + // act on the page, then return text content + return { content: [ { type: 'text', text: `Added ${ quantity }.` } ] }; + }, +} ); +``` -An ability is exposed in a context when any of these holds: +Register before `DOMContentLoaded` finishes, or call `wpai.webmcp.refresh()` afterwards. The `wpai.webmcp.tools` filter (`@wordpress/hooks`) receives the full list and the screen name and can remove or reorder tools. -1. **Opt-in on the ability.** Add `webmcp` to its `meta` when registering it: +To load the bridge on another admin screen, add its hook suffix through the PHP filter: - ```php - 'meta' => array( - 'webmcp' => array( 'admin' => true, 'visitor' => false ), - // or 'webmcp' => true (every context), or 'webmcp' => 'admin' - ), - ``` +```php +add_filter( 'wpai_webmcp_screens', fn( array $screens ) => array_merge( $screens, array( 'edit.php' ) ) ); +``` -2. **The `wpai_webmcp_exposed_abilities` filter.** +The bridge only ships editor tools; a screen added this way needs its own. - ```php - add_filter( 'wpai_webmcp_exposed_abilities', function ( array $names, string $context ) { - if ( 'admin' === $context ) { - $names[] = 'core/get-site-info'; - } - return $names; - }, 10, 2 ); - ``` +## The per-page cap -3. **The experiment's settings.** Two text fields under Settings, AI, WebMCP: abilities exposed in wp-admin and abilities exposed to visitors, comma-separated ability names. +Agent browsers cap the tools a page may register. Registering a few hundred disabled WebMCP for the document with no error in testing, while about thirty worked. The bridge registers at most 30 tools, filterable through `wpai_webmcp_max_tools`, and logs the ones it dropped to the console. -Names that do not resolve to a registered ability are dropped silently. Abilities the current user may not run (per the ability's permission callback) are left out of the list, so the agent never sees a tool that would only answer with a permission error. +## Evals -## Tool names +`src/experiments/webmcp/evals.json` holds prompts with the tool an agent is expected to pick. `node tools/webmcp-evals.mjs` runs them against any OpenAI-compatible chat endpoint (`WEBMCP_EVAL_ENDPOINT`, `WEBMCP_EVAL_API_KEY`, `WEBMCP_EVAL_MODEL`) and reports which prompts chose the wrong tool. It does not run in CI; it exists so a change to a tool description is judged by whether a model still picks the right tool. -Ability names contain `/`, and a URL-encoded slash is rejected by stock Apache before WordPress runs (`AllowEncodedSlashes Off` is the default). Tool names therefore travel with `__` in place of `/`: the ability `core/get-post` is the tool `core__get-post`. The execute route maps the name back. An ability name must not itself contain `__`. +## Testing -## Authentication - -Requests from the bridge carry two tokens: - -- `X-WP-Nonce`: the `wp_rest` nonce that authenticates the cookie session. Core rejects any other nonce in this header before a route runs. -- `X-WPAI-WebMCP-Nonce`: the experiment's own token (action `wpai_webmcp_execute`), required on every execution. - -Both are printed with the page and refreshed from `GET /wp-json/ai/v1/webmcp/nonce` when an execution answers 403, so a page that stays open keeps working. - -## REST routes - -| Route | Method | Purpose | -| --- | --- | --- | -| `/ai/v1/webmcp/tools?context=admin` | GET | Tools the context exposes for the current user, plus fresh tokens. | -| `/ai/v1/webmcp/execute` | POST | Body: `{ "tool": "core__get-post", "context": "admin", "input": { ... } }`. Returns `{ "tool", "ability", "result" }` or the ability's own `WP_Error`. | -| `/ai/v1/webmcp/nonce` | GET | Fresh tokens. | - -## Hooks - -- `wpai_webmcp_exposed_abilities` (filter): `list $names, string $context`. Adds or removes ability names for a context. -- `wpai_webmcp_max_tools` (filter): `int $max_tools`. Default 30. - -## Testing in an agent browser - -1. Enable the experiment and expose at least one ability. The quickest way is the settings field: WordPress registers `core/get-site-info`, `core/get-user-info` and `core/get-environment-info` on every site, all read-only, so any of them works without another experiment. -2. Open a wp-admin screen in a browser that implements WebMCP. ChatGPT's in-app browser does; in Chrome, WebMCP ships behind a flag in recent builds. -3. Ask the agent to list the site's tools, then to call one. Every write still goes through the ability's permission callback. - -Without such a browser, `document.modelContext` is undefined and the bridge does nothing; the REST routes can be exercised directly with the two headers above. +- `npm run test:php -- --filter WebMCP` covers the PHP side. +- `tests/e2e/specs/experiments/webmcp.spec.js` installs a `document.modelContext` shim before the editor loads, calls the tools the way a browser would, and asserts that the title and the canvas change. +- In an agent browser, enable the experiment, open a post, and ask the agent to give the post a title and add a paragraph. Both should appear in the editor as it works. ## Prior art -This experiment follows the direction set in [#448](https://github.com/WordPress/ai/issues/448) and keeps [#224](https://github.com/WordPress/ai/pull/224) as prior art. Its three requirements (two tokens, the `__` separator, per-context curation with a cap) come from running a WordPress WebMCP bridge in production against ChatGPT's browser since August 2026. +This follows the direction set in [#448](https://github.com/WordPress/ai/issues/448), where the maintainers pointed out that WebMCP is for driving the UI on the current page rather than for exposing server-side abilities a second time. [#224](https://github.com/WordPress/ai/pull/224) remains as prior art. diff --git a/includes/Experiments/WebMCP/REST_Controller.php b/includes/Experiments/WebMCP/REST_Controller.php deleted file mode 100644 index 5478ed655..000000000 --- a/includes/Experiments/WebMCP/REST_Controller.php +++ /dev/null @@ -1,262 +0,0 @@ -curator = $curator; - } - - /** - * Registers the routes. - * - * @since x.x.x - */ - public function register_routes(): void { - $context_arg = array( - 'type' => 'string', - 'enum' => Tool_Curator::get_contexts(), - 'default' => WebMCP::CONTEXT_ADMIN, - ); - - register_rest_route( - self::NAMESPACE, - '/webmcp/tools', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'get_tools' ), - 'permission_callback' => '__return_true', - 'args' => array( 'context' => $context_arg ), - ) - ); - - register_rest_route( - self::NAMESPACE, - '/webmcp/execute', - array( - 'methods' => 'POST', - 'callback' => array( $this, 'execute' ), - 'permission_callback' => array( $this, 'check_execute_nonce' ), - 'args' => array( - 'tool' => array( - 'type' => 'string', - 'required' => true, - 'sanitize_callback' => 'sanitize_text_field', - ), - 'input' => array( - 'type' => array( 'object', 'array', 'null' ), - 'default' => null, - ), - 'context' => $context_arg, - ), - ) - ); - - register_rest_route( - self::NAMESPACE, - '/webmcp/nonce', - array( - 'methods' => 'GET', - 'callback' => array( $this, 'get_nonces' ), - 'permission_callback' => '__return_true', - ) - ); - } - - /** - * Lists the tools a context exposes for the current user. - * - * The list is public in the sense that anyone may ask; what comes back is - * filtered by the context's exposure rules and by each ability's own - * permission callback for the requesting user. - * - * @since x.x.x - * - * @param \WP_REST_Request $request Request. - * @return \WP_REST_Response Tools, how many the cap removed, and fresh tokens. - */ - public function get_tools( WP_REST_Request $request ): WP_REST_Response { - $context = (string) $request->get_param( 'context' ); - $result = $this->curator->get_tools( $context ); - - return new WP_REST_Response( - array( - 'context' => $context, - 'tools' => $result['tools'], - 'truncated' => $result['truncated'], - 'maxTools' => $this->curator->get_max_tools(), - 'nonce' => wp_create_nonce( self::NONCE_ACTION ), - 'restNonce' => wp_create_nonce( 'wp_rest' ), - ) - ); - } - - /** - * Issues fresh tokens for a page that has stayed open. - * - * @since x.x.x - * - * @return \WP_REST_Response Tokens. - */ - public function get_nonces(): WP_REST_Response { - return new WP_REST_Response( - array( - 'nonce' => wp_create_nonce( self::NONCE_ACTION ), - 'restNonce' => wp_create_nonce( 'wp_rest' ), - ) - ); - } - - /** - * Checks the experiment's own token before an execution runs. - * - * @since x.x.x - * - * @param \WP_REST_Request $request Request. - * @return true|\WP_Error True to proceed. - */ - public function check_execute_nonce( WP_REST_Request $request ) { - $nonce = $request->get_header( self::NONCE_HEADER ); - if ( ! is_string( $nonce ) || ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { - return new WP_Error( - 'wpai_webmcp_invalid_nonce', - __( 'The WebMCP token is missing or has expired. Reload the page and try again.', 'ai' ), - array( 'status' => 403 ) - ); - } - - return true; - } - - /** - * Executes one exposed ability. - * - * @since x.x.x - * - * @param \WP_REST_Request $request Request. - * @return \WP_REST_Response|\WP_Error Result, or the ability's own error. - */ - public function execute( WP_REST_Request $request ) { - $tool = (string) $request->get_param( 'tool' ); - $context = (string) $request->get_param( 'context' ); - $name = Tool_Curator::to_ability_name( $tool ); - - if ( ! $this->curator->is_exposed( $name, $context ) ) { - return new WP_Error( - 'wpai_webmcp_tool_not_exposed', - sprintf( - /* translators: %s: tool name */ - __( 'The tool "%s" is not exposed on this page.', 'ai' ), - $tool - ), - array( 'status' => 404 ) - ); - } - - $ability = wp_get_ability( $name ); - if ( ! $ability ) { - return new WP_Error( - 'wpai_webmcp_ability_not_found', - sprintf( - /* translators: %s: ability name */ - __( 'The ability "%s" is not registered.', 'ai' ), - $name - ), - array( 'status' => 404 ) - ); - } - - $input = $request->get_param( 'input' ); - $input_schema = $ability->get_input_schema(); - - if ( empty( $input_schema ) ) { - $result = $ability->execute(); - } else { - $result = $ability->execute( is_array( $input ) ? $input : array() ); - } - - if ( is_wp_error( $result ) ) { - $data = $result->get_error_data(); - if ( ! is_array( $data ) || ! isset( $data['status'] ) ) { - $status = false !== strpos( (string) $result->get_error_code(), 'permission' ) ? 403 : 400; - $result->add_data( array( 'status' => $status ) ); - } - - return $result; - } - - return new WP_REST_Response( - array( - 'tool' => $tool, - 'ability' => $name, - 'result' => $result, - ) - ); - } -} diff --git a/includes/Experiments/WebMCP/Tool_Curator.php b/includes/Experiments/WebMCP/Tool_Curator.php deleted file mode 100644 index 8978ec27a..000000000 --- a/includes/Experiments/WebMCP/Tool_Curator.php +++ /dev/null @@ -1,409 +0,0 @@ - true` (every - * context), `'webmcp' => 'admin'` or `'visitor'` (one context), or - * `'webmcp' => array( 'admin' => true, 'visitor' => false )`. - * 2. The `wpai_webmcp_exposed_abilities` filter adds its name for the context. - * 3. The site owner lists its name in the experiment's settings for the context. - * - * Abilities were written for server-side callers; a browser agent is a - * different trust context, so nothing is exposed by default. - * - * @since x.x.x - */ -class Tool_Curator { - - /** - * What stands in for `/` in a tool name on the wire. - * - * Ability names contain `/`, and a URL-encoded slash is rejected by stock - * Apache before WordPress runs. Tool names travel as `core__get-post`, and - * the execute route maps them back. An ability name must therefore not - * contain `__` itself. - * - * @since x.x.x - */ - public const SEPARATOR = '__'; - - /** - * Default cap on tools registered on one page. - * - * Agent browsers impose a per-page budget; registering a few hundred - * tools disabled WebMCP for the document with no error in testing, while - * about thirty worked. Filterable through `wpai_webmcp_max_tools`. - * - * @since x.x.x - */ - public const DEFAULT_MAX_TOOLS = 30; - - /** - * Feature ID, used to read the experiment's settings options. - * - * @since x.x.x - * @var string - */ - private string $feature_id; - - /** - * Constructor. - * - * @since x.x.x - * - * @param string $feature_id Feature ID of the experiment that owns the settings. - */ - public function __construct( string $feature_id ) { - $this->feature_id = $feature_id; - } - - /** - * Returns the page contexts the experiment knows. - * - * @since x.x.x - * - * @return list Context names. - */ - public static function get_contexts(): array { - return array( WebMCP::CONTEXT_ADMIN, WebMCP::CONTEXT_VISITOR ); - } - - /** - * Whether a string names a known context. - * - * @since x.x.x - * - * @param string $context Candidate. - * @return bool True when known. - */ - public static function is_valid_context( string $context ): bool { - return in_array( $context, self::get_contexts(), true ); - } - - /** - * Converts an ability name to the tool name sent to the browser. - * - * @since x.x.x - * - * @param string $ability_name Ability name, for example `core/get-post`. - * @return string Tool name, for example `core__get-post`. - */ - public static function to_tool_name( string $ability_name ): string { - return str_replace( '/', self::SEPARATOR, $ability_name ); - } - - /** - * Converts a tool name from the browser back to the ability name. - * - * @since x.x.x - * - * @param string $tool_name Tool name, for example `core__get-post`. - * @return string Ability name, for example `core/get-post`. - */ - public static function to_ability_name( string $tool_name ): string { - return str_replace( self::SEPARATOR, '/', $tool_name ); - } - - /** - * Returns the cap on tools per page. - * - * @since x.x.x - * - * @return int Cap, at least 1. - */ - public function get_max_tools(): int { - /** - * Filters how many tools one page may register with the browser. - * - * @since x.x.x - * - * @param int $max_tools Cap. Default 30. - */ - $max_tools = (int) apply_filters( 'wpai_webmcp_max_tools', self::DEFAULT_MAX_TOOLS ); - - return max( 1, $max_tools ); - } - - /** - * Returns the names of the abilities exposed in a context, sorted. - * - * Only registered abilities are returned; names that do not resolve are - * dropped rather than reported, because the settings field is free text. - * - * @since x.x.x - * - * @param string $context Page context. - * @return list Ability names. - */ - public function get_exposed_names( string $context ): array { - if ( ! self::is_valid_context( $context ) ) { - return array(); - } - - $names = array_merge( - $this->get_opted_in_names( $context ), - $this->get_settings_names( $context ) - ); - - /** - * Filters the abilities exposed as WebMCP tools in a page context. - * - * @since x.x.x - * - * @param list $names Ability names, for example `core/get-post`. - * @param string $context Page context: `admin` or `visitor`. - */ - $names = apply_filters( 'wpai_webmcp_exposed_abilities', $names, $context ); - - if ( ! is_array( $names ) ) { // @phpstan-ignore function.alreadyNarrowedType (the type is inferred from the default; a filter callback can return anything at runtime) - return array(); - } - - // Names come from free text and filters, so they are checked against the - // registry list rather than looked up one by one: wp_get_ability() on an - // unknown name raises a "doing it wrong" notice, which is not the caller's fault here. - $registered = $this->get_registered(); - $exposed = array(); - foreach ( $names as $name ) { - if ( ! is_string( $name ) || '' === $name ) { - continue; - } - if ( ! isset( $registered[ $name ] ) ) { - continue; - } - $exposed[ $name ] = true; - } - - $exposed = array_keys( $exposed ); - sort( $exposed ); - - return $exposed; - } - - /** - * Whether a context exposes anything at all. - * - * @since x.x.x - * - * @param string $context Page context. - * @return bool True when at least one ability is exposed. - */ - public function has_exposed_abilities( string $context ): bool { - return array() !== $this->get_exposed_names( $context ); - } - - /** - * Whether one ability is exposed in a context. - * - * @since x.x.x - * - * @param string $ability_name Ability name. - * @param string $context Page context. - * @return bool True when exposed. - */ - public function is_exposed( string $ability_name, string $context ): bool { - return in_array( $ability_name, $this->get_exposed_names( $context ), true ); - } - - /** - * Returns the tools a page registers, for the current user, within the cap. - * - * Abilities the current user may not run are left out, so the agent never - * sees a tool that would only ever answer with a permission error. - * - * @since x.x.x - * - * @param string $context Page context. - * @return array{tools: list>, truncated: int} Tools and how many were cut by the cap. - */ - public function get_tools( string $context ): array { - $registered = $this->get_registered(); - $tools = array(); - foreach ( $this->get_exposed_names( $context ) as $name ) { - $ability = $registered[ $name ] ?? null; - if ( ! $ability instanceof WP_Ability ) { - continue; - } - if ( ! $this->can_run( $ability ) ) { - continue; - } - $tools[] = $this->convert( $ability ); - } - - $max = $this->get_max_tools(); - $truncated = max( 0, count( $tools ) - $max ); - - return array( - 'tools' => array_slice( $tools, 0, $max ), - 'truncated' => $truncated, - ); - } - - /** - * Converts one ability to the tool shape WebMCP expects. - * - * @since x.x.x - * - * @param \WP_Ability $ability Ability. - * @return array Tool: name, description, inputSchema, annotations. - */ - public function convert( WP_Ability $ability ): array { - $description = trim( (string) $ability->get_description() ); - $label = trim( (string) $ability->get_label() ); - if ( '' === $description ) { - $description = $label; - } elseif ( '' !== $label && 0 !== strpos( $description, $label ) ) { - $description = $label . '. ' . $description; - } - - $input_schema = $ability->get_input_schema(); - if ( array() === $input_schema ) { - $input_schema = array( 'type' => 'object' ); - } - if ( ( $input_schema['type'] ?? null ) === 'object' && empty( $input_schema['properties'] ) ) { - // An empty PHP array encodes as `[]`; agents expect `{}` here. - $input_schema['properties'] = new \stdClass(); - } - - $meta = $ability->get_meta(); - $annotations = is_array( $meta['annotations'] ?? null ) ? $meta['annotations'] : array(); - - return array( - 'name' => self::to_tool_name( $ability->get_name() ), - 'description' => $description, - 'inputSchema' => $input_schema, - 'annotations' => array( - 'readOnlyHint' => ! empty( $annotations['readonly'] ), - 'destructiveHint' => ! empty( $annotations['destructive'] ), - 'idempotentHint' => ! empty( $annotations['idempotent'] ), - ), - ); - } - - /** - * Whether the current user may run an ability, per its own permission callback. - * - * @since x.x.x - * - * @param \WP_Ability $ability Ability. - * @return bool True when allowed. - */ - private function can_run( WP_Ability $ability ): bool { - return true === $ability->check_permissions(); - } - - /** - * Registered abilities keyed by name. - * - * @since x.x.x - * - * @return array Abilities. - */ - private function get_registered(): array { - $registered = array(); - foreach ( wp_get_abilities() as $ability ) { - if ( ! ( $ability instanceof WP_Ability ) ) { - continue; - } - - $registered[ $ability->get_name() ] = $ability; - } - - return $registered; - } - - /** - * Abilities that opt in through their meta. - * - * @since x.x.x - * - * @param string $context Page context. - * @return list Ability names. - */ - private function get_opted_in_names( string $context ): array { - $names = array(); - foreach ( $this->get_registered() as $ability ) { - $meta = $ability->get_meta(); - if ( ! self::meta_opts_in( $meta['webmcp'] ?? null, $context ) ) { - continue; - } - - $names[] = $ability->get_name(); - } - - return $names; - } - - /** - * Reads the `webmcp` meta value of an ability for one context. - * - * @since x.x.x - * - * @param mixed $value Meta value. - * @param string $context Page context. - * @return bool True when the value opts the ability into the context. - */ - public static function meta_opts_in( $value, string $context ): bool { - if ( true === $value ) { - return true; - } - if ( is_string( $value ) ) { - return $value === $context || 'all' === $value; - } - if ( is_array( $value ) ) { - return ! empty( $value[ $context ] ); - } - - return false; - } - - /** - * Abilities listed in the experiment's settings for a context. - * - * @since x.x.x - * - * @param string $context Page context. - * @return list Ability names. - */ - private function get_settings_names( string $context ): array { - $option = get_option( "wpai_feature_{$this->feature_id}_field_{$context}_abilities", '' ); - if ( ! is_string( $option ) || '' === trim( $option ) ) { - return array(); - } - - $names = array(); - $parts = preg_split( '/[\s,]+/', $option ); - if ( false === $parts ) { - return array(); - } - - foreach ( $parts as $name ) { - $name = trim( $name ); - if ( '' === $name ) { - continue; - } - - $names[] = $name; - } - - return $names; - } -} diff --git a/includes/Experiments/WebMCP/WebMCP.php b/includes/Experiments/WebMCP/WebMCP.php index 77d3bb292..58058a612 100644 --- a/includes/Experiments/WebMCP/WebMCP.php +++ b/includes/Experiments/WebMCP/WebMCP.php @@ -18,58 +18,42 @@ } /** - * Registers a curated set of WordPress abilities as WebMCP tools on the page, - * so an agent browser can call them through `document.modelContext`. + * Lets an agent browser drive the block editor through WebMCP. * - * The Abilities API stays the single registry. This experiment only decides - * which abilities a page exposes, hands them to the browser one `registerTool` - * call at a time, and executes them through a REST route that runs the - * ability's own permission and input checks on the server. + * On the post editor screens the experiment loads a bridge that registers + * a small set of tools on `document.modelContext`, one `registerTool` call + * per tool. Every tool acts on the page the person is looking at, through + * the editor's own data stores, so the title changes, the block appears and + * the post saves in front of them. Nothing is exposed that has no visible + * effect on the current page; a site that wants server-side abilities in an + * agent uses MCP. * - * Two page contexts exist because agent browsers cap the number of tools a - * page may register, and a logged-in editor and a visitor need different - * tools: `admin` for wp-admin screens and `visitor` for the front end. + * Other screens and plugins can add their own page tools through the + * bridge's JavaScript registry (`wpai.webmcp.registerTool()`) and the + * `wpai.webmcp.tools` filter. The bridge caps how many tools one page + * registers, because agent browsers cap it too. * * @since x.x.x */ class WebMCP extends Abstract_Feature { /** - * Page context for wp-admin screens. - * - * @since x.x.x - */ - public const CONTEXT_ADMIN = 'admin'; - - /** - * Page context for the front end. - * - * @since x.x.x - */ - public const CONTEXT_VISITOR = 'visitor'; - - /** - * Script handle suffix used with Asset_Loader (the loader prefixes it with `ai-`). + * Script handle suffix used with Asset_Loader (the loader prefixes it with `ai_`). * * @since x.x.x */ public const SCRIPT_HANDLE = 'webmcp'; /** - * Exposure rules for the current site. + * Default cap on tools registered on one page. * - * @since x.x.x - * @var \WordPress\AI\Experiments\WebMCP\Tool_Curator|null - */ - private ?Tool_Curator $curator = null; - - /** - * REST routes the bridge script talks to. + * Agent browsers impose a per-page budget; registering a few hundred tools + * disabled WebMCP for the document with no error in testing, while about + * thirty worked. * * @since x.x.x - * @var \WordPress\AI\Experiments\WebMCP\REST_Controller|null */ - private ?REST_Controller $rest = null; + public const DEFAULT_MAX_TOOLS = 30; /** * {@inheritDoc} @@ -84,111 +68,80 @@ public static function get_id(): string { protected function load_metadata(): array { return array( 'label' => __( 'WebMCP', 'ai' ), - 'description' => __( 'Exposes a curated set of WordPress abilities to agent browsers as WebMCP tools on the page, through document.modelContext. Nothing is exposed until an ability opts in, a filter allows it, or it is listed in the settings below.', 'ai' ), - 'category' => Experiment_Category::ADMIN, + 'description' => __( 'Lets an agent browser work in the block editor through WebMCP: set the title, insert and edit blocks, save and publish, with every change visible on the page as it happens.', 'ai' ), + 'category' => Experiment_Category::EDITOR, 'capability' => 'none', ); } - /** - * {@inheritDoc} - */ - public function get_settings_fields(): array { - return array( - array( - 'id' => 'admin_abilities', - 'label' => __( 'Abilities exposed in wp-admin (comma-separated ability names, for example core/get-site-info)', 'ai' ), - 'type' => 'string', - 'default' => '', - ), - array( - 'id' => 'visitor_abilities', - 'label' => __( 'Abilities exposed to visitors on the front end (comma-separated ability names; leave empty to load nothing on the front end)', 'ai' ), - 'type' => 'string', - 'default' => '', - ), - ); - } - /** * {@inheritDoc} */ public function register(): void { - $this->curator = new Tool_Curator( self::get_id() ); - $this->rest = new REST_Controller( $this->curator ); - - add_action( 'rest_api_init', array( $this->rest, 'register_routes' ) ); - add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); - add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_front_end_assets' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } /** - * Returns the curator, creating it when the experiment was not registered through the loader. + * Admin screens the bridge loads on. * * @since x.x.x * - * @return \WordPress\AI\Experiments\WebMCP\Tool_Curator Curator. + * @return list Hook suffixes. */ - public function get_curator(): Tool_Curator { - if ( null === $this->curator ) { - $this->curator = new Tool_Curator( self::get_id() ); - } - - return $this->curator; + public function get_screens(): array { + /** + * Filters the admin screens (hook suffixes) the WebMCP bridge loads on. + * + * The editor tools only work where the editor stores exist. A screen + * added here should register its own tools through the JavaScript + * registry, or the bridge will have nothing to register. + * + * @since x.x.x + * + * @param list $screens Hook suffixes. Default the post editor screens. + */ + $screens = apply_filters( 'wpai_webmcp_screens', array( 'post.php', 'post-new.php' ) ); + + return array_values( array_filter( $screens, 'is_string' ) ); } /** - * Loads the bridge on wp-admin screens for logged-in users. - * - * Every admin screen gets the script: the tool set is the same across - * wp-admin and the browser only registers tools when it implements WebMCP. + * Cap on tools registered per page. * * @since x.x.x * - * @param string $hook_suffix Current admin page hook suffix. + * @return int Cap, at least 1. */ - public function enqueue_admin_assets( string $hook_suffix ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Signature of the admin_enqueue_scripts hook. - if ( ! is_user_logged_in() ) { - return; - } - - if ( ! $this->get_curator()->has_exposed_abilities( self::CONTEXT_ADMIN ) ) { - return; - } - - $this->enqueue_bridge( self::CONTEXT_ADMIN ); + public function get_max_tools(): int { + /** + * Filters how many tools one page may register with the browser. + * + * @since x.x.x + * + * @param int $max_tools Cap. Default 30. + */ + $max_tools = (int) apply_filters( 'wpai_webmcp_max_tools', self::DEFAULT_MAX_TOOLS ); + + return max( 1, $max_tools ); } /** - * Loads the bridge on the front end, only when something is exposed to visitors. + * Loads the bridge on the editor screens. * * @since x.x.x + * + * @param string $hook_suffix Current admin page hook suffix. */ - public function enqueue_front_end_assets(): void { - if ( ! $this->get_curator()->has_exposed_abilities( self::CONTEXT_VISITOR ) ) { + public function enqueue_assets( string $hook_suffix ): void { + if ( ! in_array( $hook_suffix, $this->get_screens(), true ) ) { return; } - $this->enqueue_bridge( self::CONTEXT_VISITOR ); - } - - /** - * Enqueues the bridge script with the data it needs to talk to the REST routes. - * - * @since x.x.x - * - * @param string $context Page context, one of the CONTEXT_* constants. - */ - private function enqueue_bridge( string $context ): void { Asset_Loader::add_global_data( 'WebMCP', array( - 'context' => $context, - 'toolsUrl' => rest_url( REST_Controller::NAMESPACE . '/webmcp/tools' ), - 'executeUrl' => rest_url( REST_Controller::NAMESPACE . '/webmcp/execute' ), - 'nonceUrl' => rest_url( REST_Controller::NAMESPACE . '/webmcp/nonce' ), - 'restNonce' => wp_create_nonce( 'wp_rest' ), - 'nonce' => wp_create_nonce( REST_Controller::NONCE_ACTION ), + 'screen' => $hook_suffix, + 'maxTools' => $this->get_max_tools(), ) ); Asset_Loader::enqueue_script( self::SCRIPT_HANDLE, 'experiments/webmcp' ); diff --git a/src/experiments/webmcp/editor-tool-definitions.mjs b/src/experiments/webmcp/editor-tool-definitions.mjs new file mode 100644 index 000000000..f61fb5fa2 --- /dev/null +++ b/src/experiments/webmcp/editor-tool-definitions.mjs @@ -0,0 +1,141 @@ +/** + * The editor tools' names, descriptions and input schemas. + * + * Plain JavaScript on purpose: the bridge imports it for registration and + * `tools/webmcp-evals.mjs` imports it to judge the descriptions with a + * model. Descriptions are written for the model, in English. + */ +export const EDITOR_TOOL_DEFINITIONS = [ + { + name: 'editor-get-document', + description: + "Read the post open in the editor: its ID, type, status, title, and an outline of its blocks with each block's clientId, block name and a short text preview. Call this first to find the clientId of a block before changing it. Changes nothing.", + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true }, + }, + { + name: 'editor-set-title', + description: + 'Set the title of the post open in the editor. The title field updates on the page.', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'The new title.' }, + }, + required: [ 'title' ], + }, + annotations: { readOnlyHint: false }, + }, + { + name: 'editor-insert-block', + description: + 'Insert a new block into the post open in the editor. Defaults to a paragraph (core/paragraph) with the given text in attributes.content; use core/heading with attributes.content and attributes.level for a heading. Appends at the end unless afterClientId names the block to insert after. The new block appears on the page and is selected.', + inputSchema: { + type: 'object', + properties: { + blockName: { + type: 'string', + description: + 'Block name, for example core/paragraph, core/heading, core/list. Default core/paragraph.', + }, + attributes: { + type: 'object', + description: + 'Block attributes. For text blocks, content is the text (inline HTML allowed).', + }, + afterClientId: { + type: 'string', + description: + 'clientId of the block to insert after. Omit to append at the end.', + }, + }, + }, + annotations: { readOnlyHint: false }, + }, + { + name: 'editor-update-block-text', + description: + 'Replace the text of an existing text block (paragraph, heading, list item, quote, verse, preformatted) identified by clientId. The block updates on the page and is selected.', + inputSchema: { + type: 'object', + properties: { + clientId: { + type: 'string', + description: 'clientId from editor-get-document.', + }, + content: { + type: 'string', + description: 'The new text (inline HTML allowed).', + }, + }, + required: [ 'clientId', 'content' ], + }, + annotations: { readOnlyHint: false }, + }, + { + name: 'editor-update-block-attributes', + description: + 'Change any attributes of an existing block identified by clientId, for example the level of a heading or the alignment of an image. Attributes not given are left as they are. The block updates on the page and is selected.', + inputSchema: { + type: 'object', + properties: { + clientId: { + type: 'string', + description: 'clientId from editor-get-document.', + }, + attributes: { + type: 'object', + description: 'Attributes to set.', + }, + }, + required: [ 'clientId', 'attributes' ], + }, + annotations: { readOnlyHint: false }, + }, + { + name: 'editor-remove-block', + description: + 'Remove an existing block identified by clientId. The block disappears from the page; the person can undo it.', + inputSchema: { + type: 'object', + properties: { + clientId: { + type: 'string', + description: 'clientId from editor-get-document.', + }, + }, + required: [ 'clientId' ], + }, + annotations: { readOnlyHint: false, destructiveHint: true }, + }, + { + name: 'editor-select-block', + description: + 'Highlight a block on the page identified by clientId, to point the person at it. Changes nothing.', + inputSchema: { + type: 'object', + properties: { + clientId: { + type: 'string', + description: 'clientId from editor-get-document.', + }, + }, + required: [ 'clientId' ], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'editor-save', + description: + 'Save the post open in the editor without changing its status: a draft stays a draft, a published post updates. Returns the status.', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: false, idempotentHint: true }, + }, + { + name: 'editor-publish', + description: + 'Publish the post open in the editor: its status becomes published and it is saved. Visible to the public afterwards, so confirm with the person before calling this.', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: false }, + }, +]; diff --git a/src/experiments/webmcp/editor-tools.ts b/src/experiments/webmcp/editor-tools.ts new file mode 100644 index 000000000..db0a333ee --- /dev/null +++ b/src/experiments/webmcp/editor-tools.ts @@ -0,0 +1,280 @@ +/** + * WordPress dependencies + */ +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { createBlock } from '@wordpress/blocks'; +import { dispatch, select } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; + +/** + * Internal dependencies + */ +import { EDITOR_TOOL_DEFINITIONS } from './editor-tool-definitions.mjs'; +import type { ToolDefinition, ToolResult, WebMCPTool } from './types'; + +/** What a tool may receive. Every field is checked before use. */ +interface ToolInput { + title?: unknown; + content?: unknown; + blockName?: unknown; + attributes?: unknown; + afterClientId?: unknown; + clientId?: unknown; +} + +interface EditorBlock { + clientId: string; + name: string; + attributes: Record< string, unknown >; + innerBlocks: EditorBlock[]; +} + +/** Blocks whose visible text lives in `attributes[ 'content' ]`. */ +const TEXT_BLOCKS = new Set( [ + 'core/paragraph', + 'core/heading', + 'core/list-item', + 'core/quote', + 'core/verse', + 'core/preformatted', + 'core/code', + 'core/pullquote', +] ); + +const text = ( value: unknown ): ToolResult => ( { + content: [ + { + type: 'text', + text: typeof value === 'string' ? value : JSON.stringify( value ), + }, + ], +} ); + +const asInput = ( value: unknown ): ToolInput => + value && typeof value === 'object' && ! Array.isArray( value ) + ? ( value as ToolInput ) + : {}; + +const asObject = ( value: unknown ): Record< string, unknown > => + value && typeof value === 'object' && ! Array.isArray( value ) + ? ( value as Record< string, unknown > ) + : {}; + +const asString = ( value: unknown, name: string ): string => { + if ( typeof value !== 'string' || value === '' ) { + throw new Error( `${ name } is required.` ); + } + return value; +}; + +/** Plain-text preview of a block's content attribute, HTML stripped. */ +const preview = ( attributes: { content?: unknown } ): string => { + const content = attributes.content; + let raw = ''; + if ( typeof content === 'string' ) { + raw = content; + } else if ( content && typeof content === 'object' ) { + raw = String( content ); + } + return raw + .replace( /<[^>]+>/g, '' ) + .replace( /\s+/g, ' ' ) + .trim() + .slice( 0, 120 ); +}; + +const outline = ( + blocks: EditorBlock[], + depth = 0 +): Array< { + clientId: string; + name: string; + depth: number; + text: string; +} > => + blocks.flatMap( ( block ) => [ + { + clientId: block.clientId, + name: block.name, + depth, + text: preview( block.attributes ), + }, + ...outline( block.innerBlocks || [], depth + 1 ), + ] ); + +// The editor stores are typed for React hooks in this repository; the bridge +// calls them imperatively, so the few selectors and actions it needs are +// declared here rather than cast at every call site. +interface EditorSelectors { + getCurrentPostId: () => number | null; + getCurrentPostType: () => string; + getEditedPostAttribute: ( attribute: string ) => unknown; + isSavingPost: () => boolean; +} +interface EditorActions { + editPost: ( edits: Record< string, unknown > ) => unknown; + savePost: () => Promise< unknown >; +} +interface BlockEditorSelectors { + getBlocks: () => EditorBlock[]; + getBlock: ( clientId: string ) => EditorBlock | null; + getBlockIndex: ( clientId: string ) => number; + getBlockRootClientId: ( clientId: string ) => string; +} +interface BlockEditorActions { + insertBlock: ( + block: unknown, + index?: number, + rootClientId?: string + ) => unknown; + updateBlockAttributes: ( + clientId: string, + attributes: Record< string, unknown > + ) => unknown; + removeBlock: ( clientId: string ) => unknown; + selectBlock: ( clientId: string ) => unknown; +} + +const editorSelect = () => select( editorStore ) as unknown as EditorSelectors; +const editorDispatch = () => + dispatch( editorStore ) as unknown as EditorActions; +const blocksSelect = () => + select( blockEditorStore ) as unknown as BlockEditorSelectors; +const blocksDispatch = () => + dispatch( blockEditorStore ) as unknown as BlockEditorActions; + +const requireBlock = ( clientId: unknown ): EditorBlock => { + const id = asString( clientId, 'clientId' ); + const block = blocksSelect().getBlock( id ); + if ( ! block ) { + throw new Error( + `No block with clientId ${ id }. Call editor-get-document for the current outline.` + ); + } + return block; +}; + +const document = () => { + const editor = editorSelect(); + return { + postId: editor.getCurrentPostId(), + postType: editor.getCurrentPostType(), + status: editor.getEditedPostAttribute( 'status' ), + title: editor.getEditedPostAttribute( 'title' ), + blocks: outline( blocksSelect().getBlocks() ), + }; +}; + +const save = async () => { + await editorDispatch().savePost(); + const editor = editorSelect(); + return { + postId: editor.getCurrentPostId(), + status: editor.getEditedPostAttribute( 'status' ), + saving: editor.isSavingPost(), + }; +}; + +const implementations: Record< + string, + ( input: ToolInput ) => Promise< unknown > | unknown +> = { + 'editor-get-document': () => document(), + + 'editor-set-title': ( input ) => { + const title = asString( input.title, 'title' ); + editorDispatch().editPost( { title } ); + return { title }; + }, + + 'editor-insert-block': ( input ) => { + const name = + typeof input.blockName === 'string' && input.blockName + ? input.blockName + : 'core/paragraph'; + const attributes = asObject( input.attributes ); + const block = createBlock( name, attributes ) as unknown as EditorBlock; + + let index: number | undefined; + let rootClientId: string | undefined; + if ( typeof input.afterClientId === 'string' && input.afterClientId ) { + const after = requireBlock( input.afterClientId ); + index = blocksSelect().getBlockIndex( after.clientId ) + 1; + rootClientId = + blocksSelect().getBlockRootClientId( after.clientId ) || + undefined; + } + + blocksDispatch().insertBlock( block, index, rootClientId ); + blocksDispatch().selectBlock( block.clientId ); + return { clientId: block.clientId, name }; + }, + + 'editor-update-block-text': ( input ) => { + const block = requireBlock( input.clientId ); + if ( ! TEXT_BLOCKS.has( block.name ) ) { + throw new Error( + `${ block.name } has no text content; use editor-update-block-attributes.` + ); + } + const content = asString( input.content, 'content' ); + blocksDispatch().updateBlockAttributes( block.clientId, { content } ); + blocksDispatch().selectBlock( block.clientId ); + return { clientId: block.clientId, content }; + }, + + 'editor-update-block-attributes': ( input ) => { + const block = requireBlock( input.clientId ); + const attributes = asObject( input.attributes ); + if ( Object.keys( attributes ).length === 0 ) { + throw new Error( 'attributes must have at least one key.' ); + } + blocksDispatch().updateBlockAttributes( block.clientId, attributes ); + blocksDispatch().selectBlock( block.clientId ); + return { clientId: block.clientId, attributes }; + }, + + 'editor-remove-block': ( input ) => { + const block = requireBlock( input.clientId ); + blocksDispatch().removeBlock( block.clientId ); + return { removed: block.clientId, name: block.name }; + }, + + 'editor-select-block': ( input ) => { + const block = requireBlock( input.clientId ); + blocksDispatch().selectBlock( block.clientId ); + return { clientId: block.clientId, name: block.name }; + }, + + 'editor-save': () => save(), + + 'editor-publish': async () => { + editorDispatch().editPost( { status: 'publish' } ); + return save(); + }, +}; + +/** Whether the editor stores exist on this page. */ +export const hasEditor = (): boolean => { + try { + return typeof editorSelect().getCurrentPostId === 'function'; + } catch { + return false; + } +}; + +/** The editor tools, ready to register. */ +export const getEditorTools = (): WebMCPTool[] => + ( EDITOR_TOOL_DEFINITIONS as ToolDefinition[] ).map( ( definition ) => ( { + ...definition, + execute: async ( input: unknown ) => { + const run = implementations[ definition.name ]; + if ( ! run ) { + throw new Error( + `${ definition.name } has no implementation.` + ); + } + const result = await run( asInput( input ) ); + return text( result ); + }, + } ) ); diff --git a/src/experiments/webmcp/evals.json b/src/experiments/webmcp/evals.json new file mode 100644 index 000000000..08071965e --- /dev/null +++ b/src/experiments/webmcp/evals.json @@ -0,0 +1,15 @@ +{ + "description": "Prompts with the tool an agent is expected to choose. Run with: node tools/webmcp-evals.mjs", + "cases": [ + { "prompt": "Call this post 'Autumn opening hours'.", "expect": "editor-set-title" }, + { "prompt": "What is in this post right now?", "expect": "editor-get-document" }, + { "prompt": "Add a paragraph at the end that says we close at 5pm on Fridays.", "expect": "editor-insert-block" }, + { "prompt": "Add a heading 'Contact' after the second paragraph.", "expect": "editor-insert-block" }, + { "prompt": "Change the first paragraph to say we open at 9.", "expect": "editor-update-block-text" }, + { "prompt": "Make that heading a level 3.", "expect": "editor-update-block-attributes" }, + { "prompt": "Delete the last block.", "expect": "editor-remove-block" }, + { "prompt": "Show me which block you mean.", "expect": "editor-select-block" }, + { "prompt": "Save this as a draft.", "expect": "editor-save" }, + { "prompt": "Publish it.", "expect": "editor-publish" } + ] +} diff --git a/src/experiments/webmcp/index.ts b/src/experiments/webmcp/index.ts index 83b9f8a6b..da0a11c31 100644 --- a/src/experiments/webmcp/index.ts +++ b/src/experiments/webmcp/index.ts @@ -1,51 +1,40 @@ /** * WebMCP bridge. * - * Registers the tools the server exposes for this page on - * `document.modelContext`, one `registerTool` call per tool, and executes - * them through the experiment's REST route. No WordPress packages are - * imported on purpose: the script also loads on the front end. + * Collects page tools from the registry, runs them through the + * `wpai.webmcp.tools` filter, and registers each one on + * `document.modelContext` with one `registerTool` call, up to the per-page + * cap. The built-in editor tools register themselves when the editor stores + * exist on the page. * - * Findings this follows, from running a WordPress bridge against ChatGPT's - * in-app browser: `modelContext` lives on `document` (with `navigator` as a - * fallback for older builds) and is a frozen object that implements only - * `registerTool`; batch `provideContext` throws. Tools are registered one at - * a time and each failure is swallowed so one bad schema does not take the - * rest down. + * `modelContext` lives on `document` (with `navigator` kept as a fallback + * for older builds) and, in ChatGPT's browser, is a frozen object that + * implements only `registerTool`; batch `provideContext` throws. Every + * registration failure is swallowed so one bad tool does not take the rest + * down. */ -interface BridgeData { - context: string; - toolsUrl: string; - executeUrl: string; - nonceUrl: string; - restNonce: string; - nonce: string; -} - -interface ToolDefinition { - name: string; - description: string; - inputSchema: Record< string, unknown >; - annotations?: Record< string, unknown >; -} - -interface RegisteredTool extends ToolDefinition { - execute: ( input: unknown ) => Promise< ToolResult >; -} - -interface ToolResult { - content: Array< { type: 'text'; text: string } >; -} +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; +import { applyFilters } from '@wordpress/hooks'; -interface ModelContext { - registerTool?: ( tool: RegisteredTool ) => unknown; - provideContext?: ( context: { tools: RegisteredTool[] } ) => unknown; -} +/** + * Internal dependencies + */ +import { getEditorTools, hasEditor } from './editor-tools'; +import type { + BridgeData, + ModelContext, + WebMCPRegistry, + WebMCPTool, +} from './types'; declare global { interface Window { aiWebMCP?: BridgeData; + wpai?: { webmcp?: WebMCPRegistry } & Record< string, unknown >; } interface Document { modelContext?: ModelContext; @@ -56,180 +45,105 @@ declare global { } const getModelContext = (): ModelContext | null => { - if ( typeof document !== 'undefined' && document.modelContext ) { + if ( document.modelContext ) { return document.modelContext; } - if ( typeof navigator !== 'undefined' && navigator.modelContext ) { + if ( navigator.modelContext ) { return navigator.modelContext; } return null; }; -const toTextResult = ( value: unknown ): ToolResult => ( { - content: [ - { - type: 'text', - text: typeof value === 'string' ? value : JSON.stringify( value ), - }, - ], -} ); - -const bridge = ( data: BridgeData, modelContext: ModelContext ) => { - let { nonce, restNonce } = data; - - const headers = ( withToken: boolean ): Record< string, string > => { - const result: Record< string, string > = { - 'Content-Type': 'application/json', - }; - if ( restNonce ) { - // The cookie session's own nonce. Core rejects anything else in - // this header, which is why the experiment's token has its own. - result[ 'X-WP-Nonce' ] = restNonce; - } - if ( withToken && nonce ) { - result[ 'X-WPAI-WebMCP-Nonce' ] = nonce; - } - return result; - }; +const data: BridgeData = window.aiWebMCP ?? { screen: '', maxTools: 30 }; +const custom: WebMCPTool[] = []; +const registeredNames = new Set< string >(); + +const isTool = ( tool: unknown ): tool is WebMCPTool => { + const candidate = tool as Partial< WebMCPTool > | null; + return Boolean( + candidate && + typeof candidate.name === 'string' && + candidate.name && + typeof candidate.description === 'string' && + typeof candidate.execute === 'function' + ); +}; - const refreshNonces = async (): Promise< void > => { - try { - const response = await fetch( data.nonceUrl, { - credentials: 'same-origin', - headers: headers( false ), - } ); - if ( ! response.ok ) { - return; - } - const json = await response.json(); - if ( typeof json?.nonce === 'string' ) { - nonce = json.nonce; - } - if ( typeof json?.restNonce === 'string' ) { - restNonce = json.restNonce; +const collect = (): WebMCPTool[] => { + const tools = [ ...( hasEditor() ? getEditorTools() : [] ), ...custom ]; + const filtered = applyFilters( + 'wpai.webmcp.tools', + tools, + data.screen + ) as unknown; + return ( Array.isArray( filtered ) ? filtered : tools ).filter( isTool ); +}; + +const register = () => { + const modelContext = getModelContext(); + if ( ! modelContext ) { + return; + } + + const tools = collect().filter( + ( tool ) => ! registeredNames.has( tool.name ) + ); + const room = Math.max( 0, data.maxTools - registeredNames.size ); + const dropped = tools.slice( room ); + if ( dropped.length > 0 ) { + // eslint-disable-next-line no-console + console.warn( + `WebMCP: ${ dropped.length } tool(s) not registered, the page cap is ${ data.maxTools }:`, + dropped.map( ( tool ) => tool.name ).join( ', ' ) + ); + } + + const toRegister = tools.slice( 0, room ).map( ( tool ) => ( { + ...tool, + inputSchema: tool.inputSchema ?? { type: 'object' }, + } ) ); + + if ( typeof modelContext.registerTool === 'function' ) { + for ( const tool of toRegister ) { + try { + const result = modelContext.registerTool( tool ) as + | { catch?: ( handler: () => void ) => unknown } + | undefined; + result?.catch?.( () => {} ); + registeredNames.add( tool.name ); + } catch { + // One bad tool must not stop the others. } - } catch { - // A failed refresh surfaces on the next execution as a 403. - } - }; - - const loadTools = async (): Promise< ToolDefinition[] > => { - const url = new URL( data.toolsUrl, window.location.href ); - url.searchParams.set( 'context', data.context ); - const response = await fetch( url.toString(), { - credentials: 'same-origin', - headers: headers( false ), - } ); - if ( ! response.ok ) { - return []; - } - const json = await response.json(); - if ( typeof json?.nonce === 'string' ) { - nonce = json.nonce; - } - if ( typeof json?.restNonce === 'string' ) { - restNonce = json.restNonce; - } - return Array.isArray( json?.tools ) ? json.tools : []; - }; - - const post = ( body: string ): Promise< Response > => - fetch( data.executeUrl, { - method: 'POST', - credentials: 'same-origin', - headers: headers( true ), - body, - } ); - - const runTool = async ( - name: string, - input: unknown - ): Promise< ToolResult > => { - const body = JSON.stringify( { - tool: name, - context: data.context, - input: input && typeof input === 'object' ? input : {}, - } ); - - let response = await post( body ); - if ( response.status === 403 ) { - // Tokens expire while a page stays open. Refresh once, then retry. - await refreshNonces(); - response = await post( body ); } + return; + } - let json: { - result?: unknown; - message?: string; - code?: string; - } = {}; + if ( typeof modelContext.provideContext === 'function' ) { try { - json = await response.json(); + modelContext.provideContext( { tools: toRegister } ); + toRegister.forEach( ( tool ) => registeredNames.add( tool.name ) ); } catch { - // A non-JSON body is reported through the status below. + // Older polyfills only. } + } +}; - if ( ! response.ok ) { +const registry: WebMCPRegistry = { + registerTool: ( tool ) => { + if ( ! isTool( tool ) ) { throw new Error( - json.message ?? `WebMCP: HTTP ${ response.status }` - ); - } - - return toTextResult( json.result ); - }; - - const registerTools = ( tools: ToolDefinition[] ) => { - const registered = tools - .filter( ( tool ) => tool && tool.name && tool.description ) - .map( - ( tool ): RegisteredTool => ( { - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema ?? { type: 'object' }, - ...( tool.annotations - ? { annotations: tool.annotations } - : {} ), - execute: ( input: unknown ) => runTool( tool.name, input ), - } ) + 'A WebMCP tool needs a name, a description and an execute function.' ); - - if ( registered.length === 0 ) { - return; - } - - if ( typeof modelContext.registerTool === 'function' ) { - for ( const tool of registered ) { - try { - const result = modelContext.registerTool( tool ) as - | { catch?: ( handler: () => void ) => unknown } - | undefined; - result?.catch?.( () => {} ); - } catch { - // One bad tool must not stop the others. - } - } - return; - } - - if ( typeof modelContext.provideContext === 'function' ) { - try { - modelContext.provideContext( { tools: registered } ); - } catch { - // Older polyfills only; nothing to do when this throws. - } } - }; - - loadTools() - .then( registerTools ) - .catch( () => {} ); + custom.push( tool ); + }, + getTools: () => collect(), + refresh: register, }; -const data = window.aiWebMCP; -const modelContext = getModelContext(); +window.wpai = window.wpai ?? {}; +window.wpai.webmcp = registry; -if ( data && modelContext ) { - bridge( data, modelContext ); -} +domReady( register ); export {}; diff --git a/src/experiments/webmcp/types.ts b/src/experiments/webmcp/types.ts new file mode 100644 index 000000000..c0e6991bc --- /dev/null +++ b/src/experiments/webmcp/types.ts @@ -0,0 +1,30 @@ +export interface ToolResult { + content: Array< { type: 'text'; text: string } >; +} + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: Record< string, unknown >; + annotations?: Record< string, unknown >; +} + +export interface WebMCPTool extends ToolDefinition { + execute: ( input: unknown ) => Promise< ToolResult >; +} + +export interface ModelContext { + registerTool?: ( tool: WebMCPTool ) => unknown; + provideContext?: ( context: { tools: WebMCPTool[] } ) => unknown; +} + +export interface BridgeData { + screen: string; + maxTools: number; +} + +export interface WebMCPRegistry { + registerTool: ( tool: WebMCPTool ) => void; + getTools: () => WebMCPTool[]; + refresh: () => void; +} diff --git a/tests/Integration/Includes/Experiments/WebMCP/REST_ControllerTest.php b/tests/Integration/Includes/Experiments/WebMCP/REST_ControllerTest.php deleted file mode 100644 index 8bcdb5a23..000000000 --- a/tests/Integration/Includes/Experiments/WebMCP/REST_ControllerTest.php +++ /dev/null @@ -1,224 +0,0 @@ - - */ - private array $registered = array(); - - /** - * Registers the routes and two abilities: one exposed to admins, one to visitors. - */ - public function setUp(): void { - parent::setUp(); - - $controller = new REST_Controller( new Tool_Curator( 'webmcp' ) ); - add_action( 'rest_api_init', array( $controller, 'register_routes' ) ); - do_action( 'rest_api_init', rest_get_server() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook fired to register the routes under test. - - $this->register_ability( - 'webmcp-test/echo', - array( - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( 'text' => array( 'type' => 'string' ) ), - ), - 'execute_callback' => static function ( $input ) { - return array( 'echo' => $input['text'] ?? '' ); - }, - 'meta' => array( 'webmcp' => 'admin' ), - ) - ); - $this->register_ability( 'webmcp-test/public', array( 'meta' => array( 'webmcp' => 'visitor' ) ) ); - } - - /** - * Cleans up. - */ - public function tearDown(): void { - foreach ( $this->registered as $name ) { - wp_unregister_ability( $name ); - } - $this->registered = array(); - wp_set_current_user( 0 ); - remove_all_actions( 'rest_api_init' ); - parent::tearDown(); - } - - /** - * Registers a test ability inside the abilities init context. - * - * @param string $name Ability name. - * @param array $args Overrides. - */ - private function register_ability( string $name, array $args = array() ): void { - global $wp_current_filter; - - $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register a test ability. - - try { - wp_register_ability( - $name, - array_merge( - array( - 'label' => 'Test ' . $name, - 'description' => 'Test ability ' . $name, - 'category' => WPAI_DEFAULT_ABILITY_CATEGORY, - 'execute_callback' => static function () use ( $name ) { - return array( 'ran' => $name ); - }, - 'permission_callback' => '__return_true', - ), - $args - ) - ); - } finally { - array_pop( $wp_current_filter ); - } - - $this->registered[] = $name; - } - - /** - * Tests that the tools route filters by context and returns tokens. - */ - public function test_tools_route_lists_per_context(): void { - $request = new WP_REST_Request( 'GET', '/ai/v1/webmcp/tools' ); - $request->set_param( 'context', WebMCP::CONTEXT_ADMIN ); - $data = rest_get_server()->dispatch( $request )->get_data(); - - $this->assertSame( WebMCP::CONTEXT_ADMIN, $data['context'] ); - $this->assertSame( array( 'webmcp-test__echo' ), array_column( $data['tools'], 'name' ) ); - $this->assertSame( 0, $data['truncated'] ); - $this->assertSame( 30, $data['maxTools'] ); - $this->assertNotEmpty( $data['nonce'] ); - $this->assertNotEmpty( $data['restNonce'] ); - - $request = new WP_REST_Request( 'GET', '/ai/v1/webmcp/tools' ); - $request->set_param( 'context', WebMCP::CONTEXT_VISITOR ); - $data = rest_get_server()->dispatch( $request )->get_data(); - - $this->assertSame( array( 'webmcp-test__public' ), array_column( $data['tools'], 'name' ) ); - } - - /** - * Tests that an unknown context is rejected by the schema. - */ - public function test_tools_route_rejects_unknown_context(): void { - $request = new WP_REST_Request( 'GET', '/ai/v1/webmcp/tools' ); - $request->set_param( 'context', 'editor' ); - - $this->assertSame( 400, rest_get_server()->dispatch( $request )->get_status() ); - } - - /** - * Tests that execution without the experiment's token is refused. - */ - public function test_execute_requires_the_experiment_token(): void { - $request = new WP_REST_Request( 'POST', '/ai/v1/webmcp/execute' ); - $request->set_body_params( array( 'tool' => 'webmcp-test__echo' ) ); - - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 403, $response->get_status() ); - $this->assertSame( 'wpai_webmcp_invalid_nonce', $response->as_error()->get_error_code() ); - } - - /** - * Tests a full execution: name mapping, input, result. - */ - public function test_execute_runs_an_exposed_ability(): void { - $request = new WP_REST_Request( 'POST', '/ai/v1/webmcp/execute' ); - $request->set_header( REST_Controller::NONCE_HEADER, wp_create_nonce( REST_Controller::NONCE_ACTION ) ); - $request->set_body_params( - array( - 'tool' => 'webmcp-test__echo', - 'context' => WebMCP::CONTEXT_ADMIN, - 'input' => array( 'text' => 'hello' ), - ) - ); - - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - $this->assertSame( 'webmcp-test/echo', $response->get_data()['ability'] ); - $this->assertSame( array( 'echo' => 'hello' ), $response->get_data()['result'] ); - } - - /** - * Tests that a tool not exposed in the requested context cannot be run through the route. - */ - public function test_execute_refuses_a_tool_outside_its_context(): void { - $request = new WP_REST_Request( 'POST', '/ai/v1/webmcp/execute' ); - $request->set_header( REST_Controller::NONCE_HEADER, wp_create_nonce( REST_Controller::NONCE_ACTION ) ); - $request->set_body_params( - array( - 'tool' => 'webmcp-test__echo', - 'context' => WebMCP::CONTEXT_VISITOR, - ) - ); - - $response = rest_get_server()->dispatch( $request ); - - $this->assertSame( 404, $response->get_status() ); - $this->assertSame( 'wpai_webmcp_tool_not_exposed', $response->as_error()->get_error_code() ); - } - - /** - * Tests that the ability's own permission callback still decides. - */ - public function test_execute_returns_the_permission_error_of_the_ability(): void { - $this->register_ability( - 'webmcp-test/locked', - array( - 'meta' => array( 'webmcp' => 'admin' ), - 'permission_callback' => '__return_false', - ) - ); - - $request = new WP_REST_Request( 'POST', '/ai/v1/webmcp/execute' ); - $request->set_header( REST_Controller::NONCE_HEADER, wp_create_nonce( REST_Controller::NONCE_ACTION ) ); - $request->set_body_params( - array( - 'tool' => 'webmcp-test__locked', - 'context' => WebMCP::CONTEXT_ADMIN, - ) - ); - - $response = rest_get_server()->dispatch( $request ); - - $this->assertTrue( $response->is_error() ); - $this->assertSame( 403, $response->get_status() ); - } - - /** - * Tests the nonce route. - */ - public function test_nonce_route_issues_both_tokens(): void { - $data = rest_get_server()->dispatch( new WP_REST_Request( 'GET', '/ai/v1/webmcp/nonce' ) )->get_data(); - - $this->assertSame( 1, wp_verify_nonce( $data['nonce'], REST_Controller::NONCE_ACTION ) ); - $this->assertSame( 1, wp_verify_nonce( $data['restNonce'], 'wp_rest' ) ); - } -} diff --git a/tests/Integration/Includes/Experiments/WebMCP/Tool_CuratorTest.php b/tests/Integration/Includes/Experiments/WebMCP/Tool_CuratorTest.php deleted file mode 100644 index 8f1b09810..000000000 --- a/tests/Integration/Includes/Experiments/WebMCP/Tool_CuratorTest.php +++ /dev/null @@ -1,251 +0,0 @@ - - */ - private array $registered = array(); - - /** - * Curator under test. - * - * @var \WordPress\AI\Experiments\WebMCP\Tool_Curator - */ - private Tool_Curator $curator; - - /** - * Sets up the curator. - */ - public function setUp(): void { - parent::setUp(); - $this->curator = new Tool_Curator( 'webmcp' ); - } - - /** - * Cleans up. - */ - public function tearDown(): void { - foreach ( $this->registered as $name ) { - wp_unregister_ability( $name ); - } - $this->registered = array(); - wp_set_current_user( 0 ); - delete_option( 'wpai_feature_webmcp_field_admin_abilities' ); - delete_option( 'wpai_feature_webmcp_field_visitor_abilities' ); - remove_all_filters( 'wpai_webmcp_exposed_abilities' ); - remove_all_filters( 'wpai_webmcp_max_tools' ); - parent::tearDown(); - } - - /** - * Registers a test ability inside the abilities init context. - * - * @param string $name Ability name. - * @param array $args Overrides. - */ - private function register_ability( string $name, array $args = array() ): void { - global $wp_current_filter; - - $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register a test ability. - - try { - wp_register_ability( - $name, - array_merge( - array( - 'label' => 'Test ' . $name, - 'description' => 'Test ability ' . $name, - 'category' => WPAI_DEFAULT_ABILITY_CATEGORY, - 'execute_callback' => static function () use ( $name ) { - return array( 'ran' => $name ); - }, - 'permission_callback' => '__return_true', - ), - $args - ) - ); - } finally { - array_pop( $wp_current_filter ); - } - - $this->registered[] = $name; - } - - /** - * Tests that nothing is exposed without an opt-in. - */ - public function test_nothing_is_exposed_by_default(): void { - $this->register_ability( 'webmcp-test/plain' ); - - $this->assertSame( array(), $this->curator->get_exposed_names( WebMCP::CONTEXT_ADMIN ) ); - $this->assertSame( array(), $this->curator->get_exposed_names( WebMCP::CONTEXT_VISITOR ) ); - $this->assertFalse( $this->curator->has_exposed_abilities( WebMCP::CONTEXT_ADMIN ) ); - } - - /** - * Tests the three meta opt-in shapes. - */ - public function test_meta_opt_in_shapes(): void { - $this->register_ability( 'webmcp-test/everywhere', array( 'meta' => array( 'webmcp' => true ) ) ); - $this->register_ability( 'webmcp-test/admin-only', array( 'meta' => array( 'webmcp' => 'admin' ) ) ); - $this->register_ability( 'webmcp-test/visitor-only', array( 'meta' => array( 'webmcp' => array( 'visitor' => true ) ) ) ); - - $this->assertSame( - array( 'webmcp-test/admin-only', 'webmcp-test/everywhere' ), - $this->curator->get_exposed_names( WebMCP::CONTEXT_ADMIN ) - ); - $this->assertSame( - array( 'webmcp-test/everywhere', 'webmcp-test/visitor-only' ), - $this->curator->get_exposed_names( WebMCP::CONTEXT_VISITOR ) - ); - } - - /** - * Tests the settings field and the filter, and that unknown names are dropped. - */ - public function test_settings_and_filter_expose_abilities(): void { - $this->register_ability( 'webmcp-test/from-settings' ); - $this->register_ability( 'webmcp-test/from-filter' ); - - update_option( 'wpai_feature_webmcp_field_admin_abilities', "webmcp-test/from-settings, does-not/exist\n" ); - add_filter( - 'wpai_webmcp_exposed_abilities', - static function ( array $names, string $context ) { - if ( WebMCP::CONTEXT_ADMIN === $context ) { - $names[] = 'webmcp-test/from-filter'; - } - return $names; - }, - 10, - 2 - ); - - $this->assertSame( - array( 'webmcp-test/from-filter', 'webmcp-test/from-settings' ), - $this->curator->get_exposed_names( WebMCP::CONTEXT_ADMIN ) - ); - $this->assertSame( array(), $this->curator->get_exposed_names( WebMCP::CONTEXT_VISITOR ) ); - $this->assertTrue( $this->curator->is_exposed( 'webmcp-test/from-filter', WebMCP::CONTEXT_ADMIN ) ); - $this->assertFalse( $this->curator->is_exposed( 'webmcp-test/from-filter', WebMCP::CONTEXT_VISITOR ) ); - } - - /** - * Tests the wire name mapping in both directions. - */ - public function test_tool_name_separator_round_trips(): void { - $this->assertSame( 'core__get-post', Tool_Curator::to_tool_name( 'core/get-post' ) ); - $this->assertSame( 'core/get-post', Tool_Curator::to_ability_name( 'core__get-post' ) ); - $this->assertSame( 'my-plugin/nested-name', Tool_Curator::to_ability_name( Tool_Curator::to_tool_name( 'my-plugin/nested-name' ) ) ); - } - - /** - * Tests the tool shape: name, description, an object schema, and annotations from the ability. - */ - public function test_convert_produces_webmcp_tool_shape(): void { - $this->register_ability( - 'webmcp-test/shape', - array( - 'label' => 'Shape', - 'description' => 'Returns a shape.', - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( 'id' => array( 'type' => 'integer' ) ), - ), - 'meta' => array( - 'webmcp' => true, - 'annotations' => array( - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ), - ), - ) - ); - - $tool = $this->curator->convert( wp_get_ability( 'webmcp-test/shape' ) ); - - $this->assertSame( 'webmcp-test__shape', $tool['name'] ); - $this->assertSame( 'Shape. Returns a shape.', $tool['description'] ); - $this->assertSame( 'object', $tool['inputSchema']['type'] ); - $this->assertArrayHasKey( 'id', $tool['inputSchema']['properties'] ); - $this->assertTrue( $tool['annotations']['readOnlyHint'] ); - $this->assertFalse( $tool['annotations']['destructiveHint'] ); - $this->assertTrue( $tool['annotations']['idempotentHint'] ); - } - - /** - * Tests that an ability without an input schema gets an empty object schema, encoded as `{}` not `[]`. - */ - public function test_convert_gives_schemaless_ability_an_object_schema(): void { - $this->register_ability( 'webmcp-test/no-schema', array( 'meta' => array( 'webmcp' => true ) ) ); - - $tool = $this->curator->convert( wp_get_ability( 'webmcp-test/no-schema' ) ); - - $this->assertSame( 'object', $tool['inputSchema']['type'] ); - $this->assertStringContainsString( '"properties":{}', wp_json_encode( $tool['inputSchema'] ) ); - } - - /** - * Tests the per-page cap and the truncated count. - */ - public function test_get_tools_respects_the_cap(): void { - $this->register_ability( 'webmcp-test/one', array( 'meta' => array( 'webmcp' => true ) ) ); - $this->register_ability( 'webmcp-test/two', array( 'meta' => array( 'webmcp' => true ) ) ); - $this->register_ability( 'webmcp-test/three', array( 'meta' => array( 'webmcp' => true ) ) ); - - add_filter( 'wpai_webmcp_max_tools', static fn() => 2 ); - - $result = $this->curator->get_tools( WebMCP::CONTEXT_ADMIN ); - - $this->assertCount( 2, $result['tools'] ); - $this->assertSame( 1, $result['truncated'] ); - $this->assertSame( 2, $this->curator->get_max_tools() ); - } - - /** - * Tests that an ability the current user may not run is not listed. - */ - public function test_get_tools_hides_abilities_the_user_may_not_run(): void { - $this->register_ability( 'webmcp-test/allowed', array( 'meta' => array( 'webmcp' => true ) ) ); - $this->register_ability( - 'webmcp-test/forbidden', - array( - 'meta' => array( 'webmcp' => true ), - 'permission_callback' => '__return_false', - ) - ); - - $names = array_column( $this->curator->get_tools( WebMCP::CONTEXT_ADMIN )['tools'], 'name' ); - - $this->assertSame( array( 'webmcp-test__allowed' ), $names ); - } - - /** - * Tests that an unknown context exposes nothing. - */ - public function test_unknown_context_exposes_nothing(): void { - $this->register_ability( 'webmcp-test/everywhere', array( 'meta' => array( 'webmcp' => true ) ) ); - - $this->assertFalse( Tool_Curator::is_valid_context( 'editor' ) ); - $this->assertSame( array(), $this->curator->get_exposed_names( 'editor' ) ); - } -} diff --git a/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php b/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php index 5613d23e3..287a7deb8 100644 --- a/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php +++ b/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php @@ -1,6 +1,6 @@ - */ - private array $registered = array(); - /** * Cleans up. */ public function tearDown(): void { - foreach ( $this->registered as $name ) { - wp_unregister_ability( $name ); - } - $this->registered = array(); - wp_set_current_user( 0 ); delete_option( 'wpai_feature_webmcp_enabled' ); - delete_option( 'wpai_feature_webmcp_field_admin_abilities' ); - delete_option( 'wpai_feature_webmcp_field_visitor_abilities' ); - remove_all_filters( 'wpai_webmcp_exposed_abilities' ); + remove_all_filters( 'wpai_webmcp_screens' ); + remove_all_filters( 'wpai_webmcp_max_tools' ); remove_all_actions( 'wpai_register_features' ); wp_dequeue_script( 'ai_webmcp' ); wp_deregister_script( 'ai_webmcp' ); @@ -55,34 +42,14 @@ public function tearDown(): void { } /** - * Registers a read-only test ability inside the abilities init context. - * - * Core's own abilities are not registered in the PHPUnit context, so the - * enqueue tests bring their own. - * - * @param string $name Ability name. + * Skips a test that needs the built script when the build has not run. */ - private function register_ability( string $name ): void { - global $wp_current_filter; - - $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register a test ability. - - try { - wp_register_ability( - $name, - array( - 'label' => 'Test ' . $name, - 'description' => 'Test ability ' . $name, - 'category' => WPAI_DEFAULT_ABILITY_CATEGORY, - 'execute_callback' => '__return_true', - 'permission_callback' => '__return_true', - ) - ); - } finally { - array_pop( $wp_current_filter ); + private function require_built_script(): void { + if ( file_exists( WPAI_PLUGIN_DIR . 'build-scripts/experiments/webmcp.asset.php' ) ) { + return; } - $this->registered[] = $name; + $this->markTestSkipped( 'Scripts are not built.' ); } /** @@ -93,7 +60,7 @@ public function test_experiment_metadata(): void { $this->assertSame( 'webmcp', $experiment->get_id() ); $this->assertSame( 'WebMCP', $experiment->get_label() ); - $this->assertSame( Experiment_Category::ADMIN, $experiment->get_category() ); + $this->assertSame( Experiment_Category::EDITOR, $experiment->get_category() ); $this->assertSame( 'experimental', $experiment->get_stability() ); $this->assertSame( 'none', $experiment->get_capability() ); } @@ -115,17 +82,7 @@ public function test_experiment_is_enabled_when_option_set(): void { } /** - * Tests the two settings fields. - */ - public function test_settings_fields(): void { - $ids = array_column( ( new WebMCP() )->get_settings_fields(), 'id' ); - - $this->assertSame( array( 'admin_abilities', 'visitor_abilities' ), $ids ); - $this->assertSame( 'wpai_feature_webmcp_field_admin_abilities', WebMCP::get_field_option_name( 'admin_abilities' ) ); - } - - /** - * Tests registration through the plugin's loader and the routes it adds. + * Tests registration through the plugin's loader. */ public function test_experiment_registers_through_loader(): void { $registry = new Registry(); @@ -139,86 +96,85 @@ static function ( $reg ) { ); $loader->init(); - do_action( 'rest_api_init', rest_get_server() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core hook fired to register the routes under test. $this->assertInstanceOf( WebMCP::class, $registry->get_feature( 'webmcp' ) ); + } + + /** + * Tests the default screens and the filter. + */ + public function test_screens_default_to_the_post_editor_and_are_filterable(): void { + $experiment = new WebMCP(); + + $this->assertSame( array( 'post.php', 'post-new.php' ), $experiment->get_screens() ); + + add_filter( + 'wpai_webmcp_screens', + static function ( array $screens ) { + $screens[] = 'site-editor.php'; + $screens[] = 42; + return $screens; + } + ); - $routes = rest_get_server()->get_routes( 'ai/v1' ); - $this->assertArrayHasKey( '/ai/v1/webmcp/tools', $routes ); - $this->assertArrayHasKey( '/ai/v1/webmcp/execute', $routes ); - $this->assertArrayHasKey( '/ai/v1/webmcp/nonce', $routes ); + $this->assertSame( array( 'post.php', 'post-new.php', 'site-editor.php' ), $experiment->get_screens() ); } /** - * Tests that the bridge is not enqueued for a logged-out request to wp-admin. + * Tests the cap and its filter. */ - public function test_bridge_not_enqueued_for_logged_out_admin(): void { - wp_set_current_user( 0 ); - $this->register_ability( 'webmcp-test/admin-tool' ); - update_option( 'wpai_feature_webmcp_field_admin_abilities', 'webmcp-test/admin-tool' ); + public function test_max_tools_default_and_filter(): void { + $experiment = new WebMCP(); + + $this->assertSame( 30, $experiment->get_max_tools() ); + + add_filter( 'wpai_webmcp_max_tools', static fn() => 0 ); + $this->assertSame( 1, $experiment->get_max_tools(), 'The cap never drops below one.' ); + } + /** + * Tests that the bridge is not loaded on screens without an editor. + */ + public function test_bridge_not_enqueued_on_other_screens(): void { $experiment = new WebMCP(); $experiment->register(); - $experiment->enqueue_admin_assets( 'index.php' ); + $experiment->enqueue_assets( 'index.php' ); $this->assertFalse( wp_script_is( 'ai_webmcp', 'enqueued' ) ); } /** - * Tests that a logged-in user on a wp-admin screen gets the bridge and its data when something is exposed. + * Tests that the editor screen gets the bridge and its data. * - * CI builds the scripts before the PHP tests run, so the asset file exists here. + * CI builds the scripts before the PHP tests run, so the asset file exists there. */ - public function test_bridge_enqueued_for_logged_in_admin_with_exposed_ability(): void { - if ( ! file_exists( WPAI_PLUGIN_DIR . 'build-scripts/experiments/webmcp.asset.php' ) ) { - $this->markTestSkipped( 'Scripts are not built.' ); - } - - wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); - $this->register_ability( 'webmcp-test/admin-tool' ); - update_option( 'wpai_feature_webmcp_field_admin_abilities', 'webmcp-test/admin-tool' ); + public function test_bridge_enqueued_on_the_editor_with_data(): void { + $this->require_built_script(); $experiment = new WebMCP(); $experiment->register(); - $experiment->enqueue_admin_assets( 'index.php' ); + $experiment->enqueue_assets( 'post.php' ); $this->assertTrue( wp_script_is( 'ai_webmcp', 'enqueued' ) ); $inline = wp_scripts()->get_data( 'ai_webmcp', 'before' ); $this->assertIsArray( $inline ); - // wp_json_encode() escapes slashes, so compare on the unescaped text. $printed = str_replace( '\\/', '/', implode( "\n", array_filter( $inline, 'is_string' ) ) ); $this->assertStringContainsString( 'window.aiWebMCP=', $printed ); - $this->assertStringContainsString( '"context":"admin"', $printed ); - $this->assertStringContainsString( '/ai/v1/webmcp/execute', $printed ); + $this->assertStringContainsString( '"screen":"post.php"', $printed ); + $this->assertStringContainsString( '"maxTools":30', $printed ); } /** - * Tests that the front end gets the bridge once a visitor ability is listed. + * Tests that the built bridge declares the editor stores it dispatches into. */ - public function test_bridge_enqueued_on_front_end_with_visitor_ability(): void { - if ( ! file_exists( WPAI_PLUGIN_DIR . 'build-scripts/experiments/webmcp.asset.php' ) ) { - $this->markTestSkipped( 'Scripts are not built.' ); - } + public function test_built_bridge_depends_on_the_editor_stores(): void { + $this->require_built_script(); - $this->register_ability( 'webmcp-test/visitor-tool' ); - update_option( 'wpai_feature_webmcp_field_visitor_abilities', 'webmcp-test/visitor-tool' ); + $asset = include WPAI_PLUGIN_DIR . 'build-scripts/experiments/webmcp.asset.php'; - $experiment = new WebMCP(); - $experiment->register(); - $experiment->enqueue_front_end_assets(); - - $this->assertTrue( wp_script_is( 'ai_webmcp', 'enqueued' ) ); - } - - /** - * Tests that the bridge is not enqueued on the front end when nothing is exposed to visitors. - */ - public function test_bridge_not_enqueued_on_front_end_without_visitor_abilities(): void { - $experiment = new WebMCP(); - $experiment->register(); - $experiment->enqueue_front_end_assets(); - - $this->assertFalse( wp_script_is( 'ai_webmcp', 'enqueued' ) ); + $this->assertContains( 'wp-data', $asset['dependencies'] ); + $this->assertContains( 'wp-blocks', $asset['dependencies'] ); + $this->assertContains( 'wp-hooks', $asset['dependencies'] ); } } diff --git a/tests/e2e/specs/experiments/webmcp.spec.js b/tests/e2e/specs/experiments/webmcp.spec.js new file mode 100644 index 000000000..11c7a792f --- /dev/null +++ b/tests/e2e/specs/experiments/webmcp.spec.js @@ -0,0 +1,124 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { enableExperiment } = require( '../../utils/helpers' ); + +/** + * Stands in for an agent browser: a frozen `document.modelContext` that only + * implements `registerTool`, which is the dialect ChatGPT's browser speaks. + * Registered tools are kept on `window.__webmcpTools` so the test can call + * them the way an agent would. + */ +const modelContextShim = () => { + const tools = []; + window.__webmcpTools = tools; + Object.defineProperty( document, 'modelContext', { + configurable: true, + value: Object.freeze( { + registerTool( tool ) { + tools.push( tool ); + return Promise.resolve(); + }, + } ), + } ); +}; + +const callTool = ( page, name, input ) => + page.evaluate( + async ( [ toolName, toolInput ] ) => { + const tool = window.__webmcpTools.find( + ( t ) => t.name === toolName + ); + if ( ! tool ) { + throw new Error( `Tool ${ toolName } is not registered.` ); + } + const result = await tool.execute( toolInput ); + return JSON.parse( result.content[ 0 ].text ); + }, + [ name, input ] + ); + +test.describe( 'WebMCP experiment', () => { + test.beforeEach( async ( { admin, page } ) => { + await enableExperiment( admin, page, 'WebMCP' ); + await page.addInitScript( modelContextShim ); + } ); + + test( 'registers the editor tools on a post and each one changes the page', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost( { postType: 'post', title: 'Before' } ); + + await page.waitForFunction( + () => ( window.__webmcpTools || [] ).length > 0 + ); + const names = await page.evaluate( () => + window.__webmcpTools.map( ( t ) => t.name ) + ); + expect( names ).toEqual( + expect.arrayContaining( [ + 'editor-get-document', + 'editor-set-title', + 'editor-insert-block', + 'editor-update-block-text', + 'editor-save', + 'editor-publish', + ] ) + ); + + // The title changes in front of the person. + await callTool( page, 'editor-set-title', { + title: 'Autumn opening hours', + } ); + await expect( + editor.canvas.locator( '.editor-post-title__input' ) + ).toHaveText( 'Autumn opening hours' ); + + // A paragraph appears in the canvas. + const inserted = await callTool( page, 'editor-insert-block', { + blockName: 'core/paragraph', + attributes: { content: 'We close at 5pm on Fridays.' }, + } ); + expect( inserted.clientId ).toBeTruthy(); + await expect( + editor.canvas.getByRole( 'document', { name: /Paragraph/ } ) + ).toContainText( 'We close at 5pm on Fridays.' ); + + // The outline names that block, and editing it by clientId updates the canvas. + const doc = await callTool( page, 'editor-get-document', {} ); + expect( doc.title ).toBe( 'Autumn opening hours' ); + expect( + doc.blocks.some( ( b ) => b.clientId === inserted.clientId ) + ).toBe( true ); + + await callTool( page, 'editor-update-block-text', { + clientId: inserted.clientId, + content: 'We open at 9.', + } ); + await expect( + editor.canvas.getByRole( 'document', { name: /Paragraph/ } ) + ).toContainText( 'We open at 9.' ); + + // Save keeps it a draft; the status is reported back. + const saved = await callTool( page, 'editor-save', {} ); + expect( saved.status ).toBe( 'draft' ); + } ); + + test( 'does not load the bridge outside the editor', async ( { + admin, + page, + } ) => { + await admin.visitAdminPage( 'index.php' ); + const registered = await page.evaluate( + () => ( window.__webmcpTools || [] ).length + ); + expect( registered ).toBe( 0 ); + } ); +} ); diff --git a/tools/webmcp-evals.mjs b/tools/webmcp-evals.mjs new file mode 100644 index 000000000..6f6d94008 --- /dev/null +++ b/tools/webmcp-evals.mjs @@ -0,0 +1,97 @@ +/** + * Runs the WebMCP tool-selection evals against an OpenAI-compatible chat + * endpoint and reports the prompts for which the model picked the wrong tool. + * + * WEBMCP_EVAL_ENDPOINT=https://api.openai.com/v1/chat/completions \ + * WEBMCP_EVAL_API_KEY=... WEBMCP_EVAL_MODEL=gpt-4o-mini \ + * node tools/webmcp-evals.mjs + * + * The tool list comes from the bridge's own definitions, so a change to a + * description is judged by whether a model still chooses the right tool. + */ +/* eslint-disable no-console */ + +/** + * External dependencies + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const here = path.dirname( fileURLToPath( import.meta.url ) ); +const evals = JSON.parse( + readFileSync( + path.join( here, '../src/experiments/webmcp/evals.json' ), + 'utf8' + ) +); +const { EDITOR_TOOL_DEFINITIONS } = await import( + path.join( here, '../src/experiments/webmcp/editor-tool-definitions.mjs' ) +); + +const { + WEBMCP_EVAL_ENDPOINT: endpoint, + WEBMCP_EVAL_API_KEY: apiKey, + WEBMCP_EVAL_MODEL: model, +} = process.env; +if ( ! endpoint || ! apiKey || ! model ) { + console.error( + 'Set WEBMCP_EVAL_ENDPOINT, WEBMCP_EVAL_API_KEY and WEBMCP_EVAL_MODEL.' + ); + process.exit( 2 ); +} + +const tools = EDITOR_TOOL_DEFINITIONS.map( + /** @param {{ name: string, description: string, inputSchema: object }} tool */ + ( tool ) => ( { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + }, + } ) +); + +let failures = 0; +for ( const { prompt, expect } of evals.cases ) { + const response = await fetch( endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${ apiKey }`, + }, + body: JSON.stringify( { + model, + messages: [ + { + role: 'system', + content: + 'You are working inside the WordPress block editor on the current page. Use the tools to act on the page.', + }, + { role: 'user', content: prompt }, + ], + tools, + tool_choice: 'required', + } ), + } ); + const json = await response.json(); + const picked = + json?.choices?.[ 0 ]?.message?.tool_calls?.[ 0 ]?.function?.name ?? + '(none)'; + const ok = picked === expect; + if ( ! ok ) { + failures++; + } + console.log( + `${ ok ? 'ok ' : 'FAIL' } ${ expect.padEnd( + 32 + ) } got ${ picked.padEnd( 32 ) } ${ prompt }` + ); +} +console.log( + `\n${ evals.cases.length - failures }/${ + evals.cases.length + } picked the expected tool.` +); +process.exit( failures ? 1 : 0 ); From 95d1acdf3685f93cd0231c05cd3a3db218e9e348 Mon Sep 17 00:00:00 2001 From: Mihai Dragomirescu Date: Thu, 1 Oct 2026 13:03:23 +0300 Subject: [PATCH 4/8] WebMCP: structural editor tools, and the editor's own rules on insert Following the pointer to Block MCP in review: an agent editing blocks needs stable references, structural operations and a way to discover what a position allows. Inside the editor those come from its own stores. Adds editor-move-block, editor-duplicate-block, editor-transform-block (through the editor's transforms), editor-get-block-types (what a position allows, or one type's attribute schema) and editor-undo (one step per tool call). editor-insert-block can now place a block inside a container, and asks canInsertBlockType first, so a locked template, an allowed-blocks list or a parent restriction refuses the insert instead of being bypassed. The e2e spec covers move, undo, duplicate, transform, discovery, nesting and a refused insert. Seven eval cases added for the new tools. Co-Authored-By: Claude Fable 5.1 --- docs/experiments/webmcp.md | 11 +- .../webmcp/editor-tool-definitions.mjs | 101 ++++++++++- src/experiments/webmcp/editor-tools.ts | 171 +++++++++++++++++- src/experiments/webmcp/evals.json | 78 +++++++- tests/e2e/specs/experiments/webmcp.spec.js | 94 ++++++++++ 5 files changed, 440 insertions(+), 15 deletions(-) diff --git a/docs/experiments/webmcp.md b/docs/experiments/webmcp.md index cac6ded9d..dd60673ad 100644 --- a/docs/experiments/webmcp.md +++ b/docs/experiments/webmcp.md @@ -2,7 +2,7 @@ ## Summary -The WebMCP experiment lets an agent browser (ChatGPT's in-app browser, Chrome builds with WebMCP behind a flag) work in the block editor. On the post editor screens it registers a small set of tools on `document.modelContext`, and every tool acts on the page the person is looking at, through the editor's own data stores: the title changes, the block appears, the post saves, in front of them, and they can stop at any point. +The WebMCP experiment lets an agent browser (ChatGPT's in-app browser, Chrome builds with WebMCP behind a flag) work in the block editor. On the post editor screens it registers fourteen tools on `document.modelContext`, and every tool acts on the page the person is looking at, through the editor's own data stores: the title changes, the block appears, the post saves, in front of them, and they can stop at any point. This is deliberately not a second door to the Abilities API. A site that wants server-side abilities in an agent connects it over MCP. WebMCP is for the page. @@ -22,11 +22,16 @@ Each tool's `execute` runs in the page and dispatches into `core/editor` and `co | --- | --- | | `editor-get-document` | Nothing changes. Returns the post ID, type, status, title, and an outline of the blocks with their `clientId`, block name and a short text preview, so the agent can refer to a block precisely. | | `editor-set-title` | The title field updates. | -| `editor-insert-block` | A new block appears, selected. Defaults to a paragraph; takes a block name, attributes, and an optional `afterClientId` to place it after a specific block. | +| `editor-insert-block` | A new block appears, selected. Defaults to a paragraph; takes a block name, attributes, and either `afterClientId` (place it after a block) or `parentClientId` (place it inside a container). Refuses a block the editor does not allow there. | | `editor-update-block-text` | The text of a paragraph, heading, list item, quote or similar block is replaced; the block is selected. | | `editor-update-block-attributes` | Any attributes of a block change; the block is selected. | | `editor-remove-block` | The block disappears. | +| `editor-move-block` | The block moves: after another block, into a container, or to the top of its parent. | +| `editor-duplicate-block` | A copy appears directly after the original. | +| `editor-transform-block` | The block becomes another type, through the same transforms the editor's own menu offers. | | `editor-select-block` | The block is highlighted, for the agent to point at something before asking. | +| `editor-get-block-types` | Nothing changes. Lists what the editor allows at a position, or returns one block type's attributes, so the agent sets attributes that exist. | +| `editor-undo` | The last change is undone, exactly like the editor's Undo button. Each tool call that changed something is one undo step. | | `editor-save` | The post saves (draft stays draft). | | `editor-publish` | The post's status changes to published and it saves. Annotated as not read-only so an agent asks first. | @@ -76,3 +81,5 @@ Agent browsers cap the tools a page may register. Registering a few hundred disa ## Prior art This follows the direction set in [#448](https://github.com/WordPress/ai/issues/448), where the maintainers pointed out that WebMCP is for driving the UI on the current page rather than for exposing server-side abilities a second time. [#224](https://github.com/WordPress/ai/pull/224) remains as prior art. + +The structural tools (insert into a container, move, duplicate, transform, discovery of block types, undo) follow what [Block MCP](https://github.com/GravityKit/block-mcp) established for atomic block editing outside the browser: an agent needs stable references, structural operations and a way to discover what a position allows. Inside the editor those come from the editor's own stores: `clientId` is the stable reference for the session, `canInsertBlockType` enforces the site's and the template's rules, attribute changes re-render through the block's own save function, and every tool call is one undo step. diff --git a/src/experiments/webmcp/editor-tool-definitions.mjs b/src/experiments/webmcp/editor-tool-definitions.mjs index f61fb5fa2..c241c1b35 100644 --- a/src/experiments/webmcp/editor-tool-definitions.mjs +++ b/src/experiments/webmcp/editor-tool-definitions.mjs @@ -29,7 +29,7 @@ export const EDITOR_TOOL_DEFINITIONS = [ { name: 'editor-insert-block', description: - 'Insert a new block into the post open in the editor. Defaults to a paragraph (core/paragraph) with the given text in attributes.content; use core/heading with attributes.content and attributes.level for a heading. Appends at the end unless afterClientId names the block to insert after. The new block appears on the page and is selected.', + 'Insert a new block into the post open in the editor. Defaults to a paragraph (core/paragraph) with the given text in attributes.content; use core/heading with attributes.content and attributes.level for a heading. Appends at the end of the post unless afterClientId names the block to insert after, or parentClientId names a container (group, column, list, quote) to insert into as its last child. Refuses a block the editor does not allow at that position. The new block appears on the page and is selected.', inputSchema: { type: 'object', properties: { @@ -48,6 +48,11 @@ export const EDITOR_TOOL_DEFINITIONS = [ description: 'clientId of the block to insert after. Omit to append at the end.', }, + parentClientId: { + type: 'string', + description: + 'clientId of a container block to insert into, as its last child. Ignored when afterClientId is given.', + }, }, }, annotations: { readOnlyHint: false }, @@ -124,6 +129,100 @@ export const EDITOR_TOOL_DEFINITIONS = [ }, annotations: { readOnlyHint: true }, }, + { + name: 'editor-move-block', + description: + 'Move an existing block identified by clientId: after the block named by afterClientId, or into the container named by parentClientId as its last child, or to the top of its current parent when neither is given. The block visibly moves on the page and is selected.', + inputSchema: { + type: 'object', + properties: { + clientId: { + type: 'string', + description: 'clientId of the block to move.', + }, + afterClientId: { + type: 'string', + description: 'clientId of the block it should follow.', + }, + parentClientId: { + type: 'string', + description: + 'clientId of a container to move it into. Ignored when afterClientId is given.', + }, + }, + required: [ 'clientId' ], + }, + annotations: { readOnlyHint: false }, + }, + { + name: 'editor-duplicate-block', + description: + 'Duplicate an existing block identified by clientId. The copy appears directly after the original. Returns the clientId of the copy.', + inputSchema: { + type: 'object', + properties: { + clientId: { + type: 'string', + description: 'clientId from editor-get-document.', + }, + }, + required: [ 'clientId' ], + }, + annotations: { readOnlyHint: false }, + }, + { + name: 'editor-transform-block', + description: + "Turn an existing block into another block type, the way the editor's own Transform menu does, for example a paragraph into a heading or a list into paragraphs. Fails when the editor has no transform between the two types. Returns the new clientIds.", + inputSchema: { + type: 'object', + properties: { + clientId: { + type: 'string', + description: 'clientId from editor-get-document.', + }, + blockName: { + type: 'string', + description: 'Target block name, for example core/heading.', + }, + }, + required: [ 'clientId', 'blockName' ], + }, + annotations: { readOnlyHint: false }, + }, + { + name: 'editor-get-block-types', + description: + "Discover what can be inserted. Without name: lists the block types the editor allows at the top level, or inside the container named by parentClientId, optionally filtered by a search word. With name: returns that block type's attributes and their types, so attributes can be set correctly. Changes nothing.", + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: + 'A block name, to get its attribute schema, for example core/image.', + }, + search: { + type: 'string', + description: + 'Filter the list by a word in the name or title.', + }, + parentClientId: { + type: 'string', + description: + 'List what is allowed inside this container instead of at the top level.', + }, + }, + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'editor-undo', + description: + "Undo the last change in the editor, exactly like the editor's own Undo button. Each tool call that changed something is one undo step. Returns the document as it is afterwards.", + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: false }, + }, { name: 'editor-save', description: diff --git a/src/experiments/webmcp/editor-tools.ts b/src/experiments/webmcp/editor-tools.ts index db0a333ee..9298a7f71 100644 --- a/src/experiments/webmcp/editor-tools.ts +++ b/src/experiments/webmcp/editor-tools.ts @@ -2,7 +2,12 @@ * WordPress dependencies */ import { store as blockEditorStore } from '@wordpress/block-editor'; -import { createBlock } from '@wordpress/blocks'; +import { + createBlock, + getBlockType, + getBlockTypes, + switchToBlockType, +} from '@wordpress/blocks'; import { dispatch, select } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; @@ -19,7 +24,10 @@ interface ToolInput { blockName?: unknown; attributes?: unknown; afterClientId?: unknown; + parentClientId?: unknown; clientId?: unknown; + name?: unknown; + search?: unknown; } interface EditorBlock { @@ -114,12 +122,15 @@ interface EditorSelectors { interface EditorActions { editPost: ( edits: Record< string, unknown > ) => unknown; savePost: () => Promise< unknown >; + undo: () => unknown; } interface BlockEditorSelectors { getBlocks: () => EditorBlock[]; getBlock: ( clientId: string ) => EditorBlock | null; getBlockIndex: ( clientId: string ) => number; getBlockRootClientId: ( clientId: string ) => string; + getBlockOrder: ( rootClientId?: string ) => string[]; + canInsertBlockType: ( name: string, rootClientId?: string ) => boolean; } interface BlockEditorActions { insertBlock: ( @@ -133,6 +144,17 @@ interface BlockEditorActions { ) => unknown; removeBlock: ( clientId: string ) => unknown; selectBlock: ( clientId: string ) => unknown; + moveBlockToPosition: ( + clientId: string, + fromRootClientId: string, + toRootClientId: string, + index: number + ) => unknown; + duplicateBlocks: ( clientIds: string[] ) => Promise< string[] | undefined >; + replaceBlocks: ( + clientIds: string | string[], + blocks: unknown[] + ) => unknown; } const editorSelect = () => select( editorStore ) as unknown as EditorSelectors; @@ -193,7 +215,6 @@ const implementations: Record< ? input.blockName : 'core/paragraph'; const attributes = asObject( input.attributes ); - const block = createBlock( name, attributes ) as unknown as EditorBlock; let index: number | undefined; let rootClientId: string | undefined; @@ -203,8 +224,22 @@ const implementations: Record< rootClientId = blocksSelect().getBlockRootClientId( after.clientId ) || undefined; + } else if ( + typeof input.parentClientId === 'string' && + input.parentClientId + ) { + rootClientId = requireBlock( input.parentClientId ).clientId; + } + + // The editor's own rules decide: locked templates, allowed block + // lists and parent restrictions all answer through this selector. + if ( ! blocksSelect().canInsertBlockType( name, rootClientId ) ) { + throw new Error( + `${ name } cannot be inserted here. Call editor-get-block-types for what this position allows.` + ); } + const block = createBlock( name, attributes ) as unknown as EditorBlock; blocksDispatch().insertBlock( block, index, rootClientId ); blocksDispatch().selectBlock( block.clientId ); return { clientId: block.clientId, name }; @@ -246,6 +281,138 @@ const implementations: Record< return { clientId: block.clientId, name: block.name }; }, + 'editor-move-block': ( input ) => { + const block = requireBlock( input.clientId ); + const select_ = blocksSelect(); + const fromRoot = select_.getBlockRootClientId( block.clientId ) || ''; + let toRoot = fromRoot; + let index = 0; + + if ( typeof input.afterClientId === 'string' && input.afterClientId ) { + const after = requireBlock( input.afterClientId ); + toRoot = select_.getBlockRootClientId( after.clientId ) || ''; + const afterIndex = select_.getBlockIndex( after.clientId ); + // Within one parent the block is removed before it is placed, so a + // move downwards lands on the target's index, not one past it. + const movingDown = + toRoot === fromRoot && + select_.getBlockIndex( block.clientId ) < afterIndex; + index = movingDown ? afterIndex : afterIndex + 1; + } else if ( + typeof input.parentClientId === 'string' && + input.parentClientId + ) { + toRoot = requireBlock( input.parentClientId ).clientId; + index = select_.getBlockOrder( toRoot ).length; + } + + if ( + toRoot !== fromRoot && + ! select_.canInsertBlockType( block.name, toRoot || undefined ) + ) { + throw new Error( `${ block.name } cannot be moved there.` ); + } + + blocksDispatch().moveBlockToPosition( + block.clientId, + fromRoot, + toRoot, + index + ); + blocksDispatch().selectBlock( block.clientId ); + return { clientId: block.clientId, index, parentClientId: toRoot }; + }, + + 'editor-duplicate-block': async ( input ) => { + const block = requireBlock( input.clientId ); + const created = await blocksDispatch().duplicateBlocks( [ + block.clientId, + ] ); + return { duplicated: block.clientId, clientIds: created ?? [] }; + }, + + 'editor-transform-block': ( input ) => { + const block = requireBlock( input.clientId ); + const target = asString( input.blockName, 'blockName' ); + const transformed = switchToBlockType( + block as unknown as Parameters< typeof switchToBlockType >[ 0 ], + target + ) as unknown as EditorBlock[] | null; + if ( ! transformed || transformed.length === 0 ) { + throw new Error( + `${ block.name } cannot be transformed into ${ target }.` + ); + } + blocksDispatch().replaceBlocks( block.clientId, transformed ); + const first = transformed[ 0 ]; + if ( first ) { + blocksDispatch().selectBlock( first.clientId ); + } + return { + replaced: block.clientId, + clientIds: transformed.map( ( item ) => item.clientId ), + name: target, + }; + }, + + 'editor-get-block-types': ( input ) => { + if ( typeof input.name === 'string' && input.name ) { + const type = getBlockType( input.name ); + if ( ! type ) { + throw new Error( `No block type named ${ input.name }.` ); + } + const attributes: Record< string, unknown > = {}; + for ( const [ key, definition ] of Object.entries( + ( type.attributes ?? {} ) as Record< + string, + { type?: unknown; enum?: unknown; default?: unknown } + > + ) ) { + attributes[ key ] = { + type: definition.type, + ...( definition.enum ? { enum: definition.enum } : {} ), + ...( definition.default !== undefined + ? { default: definition.default } + : {} ), + }; + } + return { + name: type.name, + title: type.title, + description: type.description, + attributes, + }; + } + + const parent = + typeof input.parentClientId === 'string' && input.parentClientId + ? requireBlock( input.parentClientId ).clientId + : undefined; + const search = + typeof input.search === 'string' ? input.search.toLowerCase() : ''; + const types = getBlockTypes() + .filter( ( type ) => + blocksSelect().canInsertBlockType( type.name, parent ) + ) + .filter( + ( type ) => + ! search || + type.name.toLowerCase().includes( search ) || + String( type.title ).toLowerCase().includes( search ) + ) + .map( ( type ) => ( { + name: type.name, + title: type.title, + category: type.category, + } ) ); + return { count: types.length, blockTypes: types.slice( 0, 60 ) }; + }, + + 'editor-undo': () => { + editorDispatch().undo(); + return document(); + }, + 'editor-save': () => save(), 'editor-publish': async () => { diff --git a/src/experiments/webmcp/evals.json b/src/experiments/webmcp/evals.json index 08071965e..a3e0c1c21 100644 --- a/src/experiments/webmcp/evals.json +++ b/src/experiments/webmcp/evals.json @@ -1,15 +1,73 @@ { "description": "Prompts with the tool an agent is expected to choose. Run with: node tools/webmcp-evals.mjs", "cases": [ - { "prompt": "Call this post 'Autumn opening hours'.", "expect": "editor-set-title" }, - { "prompt": "What is in this post right now?", "expect": "editor-get-document" }, - { "prompt": "Add a paragraph at the end that says we close at 5pm on Fridays.", "expect": "editor-insert-block" }, - { "prompt": "Add a heading 'Contact' after the second paragraph.", "expect": "editor-insert-block" }, - { "prompt": "Change the first paragraph to say we open at 9.", "expect": "editor-update-block-text" }, - { "prompt": "Make that heading a level 3.", "expect": "editor-update-block-attributes" }, - { "prompt": "Delete the last block.", "expect": "editor-remove-block" }, - { "prompt": "Show me which block you mean.", "expect": "editor-select-block" }, - { "prompt": "Save this as a draft.", "expect": "editor-save" }, - { "prompt": "Publish it.", "expect": "editor-publish" } + { + "prompt": "Call this post 'Autumn opening hours'.", + "expect": "editor-set-title" + }, + { + "prompt": "What is in this post right now?", + "expect": "editor-get-document" + }, + { + "prompt": "Add a paragraph at the end that says we close at 5pm on Fridays.", + "expect": "editor-insert-block" + }, + { + "prompt": "Add a heading 'Contact' after the second paragraph.", + "expect": "editor-insert-block" + }, + { + "prompt": "Change the first paragraph to say we open at 9.", + "expect": "editor-update-block-text" + }, + { + "prompt": "Make that heading a level 3.", + "expect": "editor-update-block-attributes" + }, + { + "prompt": "Delete the last block.", + "expect": "editor-remove-block" + }, + { + "prompt": "Show me which block you mean.", + "expect": "editor-select-block" + }, + { + "prompt": "Save this as a draft.", + "expect": "editor-save" + }, + { + "prompt": "Publish it.", + "expect": "editor-publish" + }, + { + "prompt": "Move the contact paragraph above the opening hours heading.", + "expect": "editor-move-block" + }, + { + "prompt": "Make a copy of that pricing table right below it.", + "expect": "editor-duplicate-block" + }, + { + "prompt": "Turn the second paragraph into a heading.", + "expect": "editor-transform-block" + }, + { + "prompt": "What attributes does an image block take?", + "expect": "editor-get-block-types" + }, + { + "prompt": "Which blocks can I put inside this columns block?", + "expect": "editor-get-block-types" + }, + { + "prompt": "That was wrong, take the last change back.", + "expect": "editor-undo" + }, + { + "prompt": "Put a button inside the group at the bottom.", + "expect": "editor-insert-block" + } ] } diff --git a/tests/e2e/specs/experiments/webmcp.spec.js b/tests/e2e/specs/experiments/webmcp.spec.js index 11c7a792f..9ccc5b603 100644 --- a/tests/e2e/specs/experiments/webmcp.spec.js +++ b/tests/e2e/specs/experiments/webmcp.spec.js @@ -111,6 +111,100 @@ test.describe( 'WebMCP experiment', () => { expect( saved.status ).toBe( 'draft' ); } ); + test( 'structural tools move, duplicate, transform, nest and undo on the page', async ( { + admin, + editor, + page, + } ) => { + await admin.createNewPost( { postType: 'post', title: 'Structure' } ); + await page.waitForFunction( + () => ( window.__webmcpTools || [] ).length > 0 + ); + + const first = await callTool( page, 'editor-insert-block', { + attributes: { content: 'First' }, + } ); + const second = await callTool( page, 'editor-insert-block', { + attributes: { content: 'Second' }, + } ); + const order = async () => + ( await callTool( page, 'editor-get-document', {} ) ).blocks.map( + ( b ) => b.text + ); + expect( await order() ).toEqual( [ 'First', 'Second' ] ); + + // Move: the first paragraph ends up after the second. + await callTool( page, 'editor-move-block', { + clientId: first.clientId, + afterClientId: second.clientId, + } ); + expect( await order() ).toEqual( [ 'Second', 'First' ] ); + + // Undo is one step per tool call and puts the order back. + await callTool( page, 'editor-undo', {} ); + expect( await order() ).toEqual( [ 'First', 'Second' ] ); + + // Duplicate: a copy appears right after the original. + const copy = await callTool( page, 'editor-duplicate-block', { + clientId: first.clientId, + } ); + expect( copy.clientIds ).toHaveLength( 1 ); + expect( await order() ).toEqual( [ 'First', 'First', 'Second' ] ); + + // Transform: the paragraph becomes a heading, as the editor's own menu would do it. + const transformed = await callTool( page, 'editor-transform-block', { + clientId: second.clientId, + blockName: 'core/heading', + } ); + expect( transformed.name ).toBe( 'core/heading' ); + await expect( + editor.canvas.getByRole( 'document', { name: /Heading/ } ) + ).toContainText( 'Second' ); + + // Discovery: a block type's attributes, and what a position allows. + const heading = await callTool( page, 'editor-get-block-types', { + name: 'core/heading', + } ); + expect( heading.attributes ).toHaveProperty( 'level' ); + const allowed = await callTool( page, 'editor-get-block-types', { + search: 'group', + } ); + expect( + allowed.blockTypes.some( ( b ) => b.name === 'core/group' ) + ).toBe( true ); + + // Nesting: a group, then a paragraph inserted into it. + const group = await callTool( page, 'editor-insert-block', { + blockName: 'core/group', + } ); + const child = await callTool( page, 'editor-insert-block', { + parentClientId: group.clientId, + attributes: { content: 'Inside the group' }, + } ); + const doc = await callTool( page, 'editor-get-document', {} ); + const nested = doc.blocks.find( + ( b ) => b.clientId === child.clientId + ); + expect( nested.depth ).toBe( 1 ); + + // A block the editor does not allow at a position is refused, not forced. + const refusal = await page.evaluate( async ( parentId ) => { + const tool = window.__webmcpTools.find( + ( t ) => t.name === 'editor-insert-block' + ); + try { + await tool.execute( { + blockName: 'core/list-item', + parentClientId: parentId, + } ); + return 'inserted'; + } catch ( error ) { + return error.message; + } + }, group.clientId ); + expect( refusal ).toContain( 'cannot be inserted here' ); + } ); + test( 'does not load the bridge outside the editor', async ( { admin, page, From 7faab236d8a8a7d225f0befcd07148f40ae63192 Mon Sep 17 00:00:00 2001 From: Mihai Dragomirescu Date: Tue, 6 Oct 2026 12:21:54 +0300 Subject: [PATCH 5/8] WebMCP: respect editor locks and report save failures --- docs/experiments/webmcp.md | 8 +- .../webmcp/editor-tool-definitions.mjs | 6 +- src/experiments/webmcp/editor-tools.ts | 75 ++++++- src/experiments/webmcp/evals.json | 73 ------- src/experiments/webmcp/index.ts | 21 +- tests/e2e-testing/e2e-testing.php | 18 ++ tests/e2e/specs/experiments/webmcp.spec.js | 205 ++++++++++++++++++ tools/webmcp-evals.mjs | 97 --------- 8 files changed, 316 insertions(+), 187 deletions(-) delete mode 100644 src/experiments/webmcp/evals.json delete mode 100644 tools/webmcp-evals.mjs diff --git a/docs/experiments/webmcp.md b/docs/experiments/webmcp.md index dd60673ad..d580f35b9 100644 --- a/docs/experiments/webmcp.md +++ b/docs/experiments/webmcp.md @@ -23,7 +23,7 @@ Each tool's `execute` runs in the page and dispatches into `core/editor` and `co | `editor-get-document` | Nothing changes. Returns the post ID, type, status, title, and an outline of the blocks with their `clientId`, block name and a short text preview, so the agent can refer to a block precisely. | | `editor-set-title` | The title field updates. | | `editor-insert-block` | A new block appears, selected. Defaults to a paragraph; takes a block name, attributes, and either `afterClientId` (place it after a block) or `parentClientId` (place it inside a container). Refuses a block the editor does not allow there. | -| `editor-update-block-text` | The text of a paragraph, heading, list item, quote or similar block is replaced; the block is selected. | +| `editor-update-block-text` | The text of a paragraph, heading, list item, verse, preformatted or code block is replaced; the block is selected. For a quote, edit its inner paragraphs. For a pullquote, set its `value` through `editor-update-block-attributes`. | | `editor-update-block-attributes` | Any attributes of a block change; the block is selected. | | `editor-remove-block` | The block disappears. | | `editor-move-block` | The block moves: after another block, into a container, or to the top of its parent. | @@ -37,6 +37,8 @@ Each tool's `execute` runs in the page and dispatches into `core/editor` and `co Tool descriptions are written for the model, in English, and are not translated. +Editor tools register only on an initialized block editor page, not in the classic editor. Updates, moves and removals respect the editor's lock selectors. Moving a block after itself leaves it unchanged; moving it into itself or a descendant is refused. Save and publish check the editor's save failure state and report an error when saving fails. + ## Adding tools from a plugin or another screen The bridge exposes a registry on `window.wpai.webmcp`: @@ -68,10 +70,6 @@ The bridge only ships editor tools; a screen added this way needs its own. Agent browsers cap the tools a page may register. Registering a few hundred disabled WebMCP for the document with no error in testing, while about thirty worked. The bridge registers at most 30 tools, filterable through `wpai_webmcp_max_tools`, and logs the ones it dropped to the console. -## Evals - -`src/experiments/webmcp/evals.json` holds prompts with the tool an agent is expected to pick. `node tools/webmcp-evals.mjs` runs them against any OpenAI-compatible chat endpoint (`WEBMCP_EVAL_ENDPOINT`, `WEBMCP_EVAL_API_KEY`, `WEBMCP_EVAL_MODEL`) and reports which prompts chose the wrong tool. It does not run in CI; it exists so a change to a tool description is judged by whether a model still picks the right tool. - ## Testing - `npm run test:php -- --filter WebMCP` covers the PHP side. diff --git a/src/experiments/webmcp/editor-tool-definitions.mjs b/src/experiments/webmcp/editor-tool-definitions.mjs index c241c1b35..4a4e79674 100644 --- a/src/experiments/webmcp/editor-tool-definitions.mjs +++ b/src/experiments/webmcp/editor-tool-definitions.mjs @@ -1,9 +1,7 @@ /** * The editor tools' names, descriptions and input schemas. * - * Plain JavaScript on purpose: the bridge imports it for registration and - * `tools/webmcp-evals.mjs` imports it to judge the descriptions with a - * model. Descriptions are written for the model, in English. + * Descriptions are written for the model, in English. */ export const EDITOR_TOOL_DEFINITIONS = [ { @@ -60,7 +58,7 @@ export const EDITOR_TOOL_DEFINITIONS = [ { name: 'editor-update-block-text', description: - 'Replace the text of an existing text block (paragraph, heading, list item, quote, verse, preformatted) identified by clientId. The block updates on the page and is selected.', + 'Replace the text of an existing paragraph, heading, list item, verse, preformatted or code block identified by clientId. For a quote, edit its inner paragraphs; for a pullquote, use editor-update-block-attributes with its value attribute. Refuses a block locked against editing. The block updates on the page and is selected.', inputSchema: { type: 'object', properties: { diff --git a/src/experiments/webmcp/editor-tools.ts b/src/experiments/webmcp/editor-tools.ts index 9298a7f71..547c2d45d 100644 --- a/src/experiments/webmcp/editor-tools.ts +++ b/src/experiments/webmcp/editor-tools.ts @@ -42,11 +42,9 @@ const TEXT_BLOCKS = new Set( [ 'core/paragraph', 'core/heading', 'core/list-item', - 'core/quote', 'core/verse', 'core/preformatted', 'core/code', - 'core/pullquote', ] ); const text = ( value: unknown ): ToolResult => ( { @@ -118,6 +116,8 @@ interface EditorSelectors { getCurrentPostType: () => string; getEditedPostAttribute: ( attribute: string ) => unknown; isSavingPost: () => boolean; + didPostSaveRequestFail: () => boolean; + isEditedPostSaveable: () => boolean; } interface EditorActions { editPost: ( edits: Record< string, unknown > ) => unknown; @@ -131,6 +131,10 @@ interface BlockEditorSelectors { getBlockRootClientId: ( clientId: string ) => string; getBlockOrder: ( rootClientId?: string ) => string[]; canInsertBlockType: ( name: string, rootClientId?: string ) => boolean; + canEditBlock: ( clientId: string ) => boolean; + canMoveBlock: ( clientId: string ) => boolean; + canRemoveBlock: ( clientId: string ) => boolean; + getBlockParents: ( clientId: string ) => string[]; } interface BlockEditorActions { insertBlock: ( @@ -188,8 +192,28 @@ const document = () => { }; const save = async () => { + if ( editorSelect().isSavingPost() ) { + throw new Error( + 'A post save is already in progress. Try again after it finishes.' + ); + } + if ( ! editorSelect().isEditedPostSaveable() ) { + throw new Error( + 'The post cannot be saved yet. Add a title or content first.' + ); + } await editorDispatch().savePost(); const editor = editorSelect(); + if ( editor.isSavingPost() ) { + throw new Error( + 'The post save has not finished. Check the editor before trying again.' + ); + } + if ( editor.didPostSaveRequestFail() ) { + throw new Error( + 'The post could not be saved. Check the error in the editor and try again.' + ); + } return { postId: editor.getCurrentPostId(), status: editor.getEditedPostAttribute( 'status' ), @@ -247,9 +271,12 @@ const implementations: Record< 'editor-update-block-text': ( input ) => { const block = requireBlock( input.clientId ); + if ( ! blocksSelect().canEditBlock( block.clientId ) ) { + throw new Error( 'This block is locked against editing.' ); + } if ( ! TEXT_BLOCKS.has( block.name ) ) { throw new Error( - `${ block.name } has no text content; use editor-update-block-attributes.` + `${ block.name } does not use a content attribute. Edit its inner text blocks or use editor-update-block-attributes with its attribute schema.` ); } const content = asString( input.content, 'content' ); @@ -260,6 +287,9 @@ const implementations: Record< 'editor-update-block-attributes': ( input ) => { const block = requireBlock( input.clientId ); + if ( ! blocksSelect().canEditBlock( block.clientId ) ) { + throw new Error( 'This block is locked against editing.' ); + } const attributes = asObject( input.attributes ); if ( Object.keys( attributes ).length === 0 ) { throw new Error( 'attributes must have at least one key.' ); @@ -271,6 +301,9 @@ const implementations: Record< 'editor-remove-block': ( input ) => { const block = requireBlock( input.clientId ); + if ( ! blocksSelect().canRemoveBlock( block.clientId ) ) { + throw new Error( 'This block is locked against removal.' ); + } blocksDispatch().removeBlock( block.clientId ); return { removed: block.clientId, name: block.name }; }, @@ -290,6 +323,13 @@ const implementations: Record< if ( typeof input.afterClientId === 'string' && input.afterClientId ) { const after = requireBlock( input.afterClientId ); + if ( after.clientId === block.clientId ) { + return { + clientId: block.clientId, + moved: false, + message: 'Nothing to move.', + }; + } toRoot = select_.getBlockRootClientId( after.clientId ) || ''; const afterIndex = select_.getBlockIndex( after.clientId ); // Within one parent the block is removed before it is placed, so a @@ -306,6 +346,27 @@ const implementations: Record< index = select_.getBlockOrder( toRoot ).length; } + if ( + toRoot === block.clientId || + ( toRoot && + select_.getBlockParents( toRoot ).includes( block.clientId ) ) + ) { + throw new Error( + 'A block cannot be moved into itself or one of its descendants.' + ); + } + if ( ! select_.canMoveBlock( block.clientId ) ) { + throw new Error( 'This block is locked against moving.' ); + } + if ( + toRoot !== fromRoot && + ! select_.canRemoveBlock( block.clientId ) + ) { + throw new Error( + 'This block is locked against removal from its current parent.' + ); + } + if ( toRoot !== fromRoot && ! select_.canInsertBlockType( block.name, toRoot || undefined ) @@ -421,10 +482,14 @@ const implementations: Record< }, }; -/** Whether the editor stores exist on this page. */ +/** Whether this page has an initialized block editor, not just its scripts. */ export const hasEditor = (): boolean => { try { - return typeof editorSelect().getCurrentPostId === 'function'; + return ( + window.document.body.classList.contains( 'block-editor-page' ) && + Boolean( editorSelect().getCurrentPostId() ) && + Array.isArray( blocksSelect().getBlocks() ) + ); } catch { return false; } diff --git a/src/experiments/webmcp/evals.json b/src/experiments/webmcp/evals.json deleted file mode 100644 index a3e0c1c21..000000000 --- a/src/experiments/webmcp/evals.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "description": "Prompts with the tool an agent is expected to choose. Run with: node tools/webmcp-evals.mjs", - "cases": [ - { - "prompt": "Call this post 'Autumn opening hours'.", - "expect": "editor-set-title" - }, - { - "prompt": "What is in this post right now?", - "expect": "editor-get-document" - }, - { - "prompt": "Add a paragraph at the end that says we close at 5pm on Fridays.", - "expect": "editor-insert-block" - }, - { - "prompt": "Add a heading 'Contact' after the second paragraph.", - "expect": "editor-insert-block" - }, - { - "prompt": "Change the first paragraph to say we open at 9.", - "expect": "editor-update-block-text" - }, - { - "prompt": "Make that heading a level 3.", - "expect": "editor-update-block-attributes" - }, - { - "prompt": "Delete the last block.", - "expect": "editor-remove-block" - }, - { - "prompt": "Show me which block you mean.", - "expect": "editor-select-block" - }, - { - "prompt": "Save this as a draft.", - "expect": "editor-save" - }, - { - "prompt": "Publish it.", - "expect": "editor-publish" - }, - { - "prompt": "Move the contact paragraph above the opening hours heading.", - "expect": "editor-move-block" - }, - { - "prompt": "Make a copy of that pricing table right below it.", - "expect": "editor-duplicate-block" - }, - { - "prompt": "Turn the second paragraph into a heading.", - "expect": "editor-transform-block" - }, - { - "prompt": "What attributes does an image block take?", - "expect": "editor-get-block-types" - }, - { - "prompt": "Which blocks can I put inside this columns block?", - "expect": "editor-get-block-types" - }, - { - "prompt": "That was wrong, take the last change back.", - "expect": "editor-undo" - }, - { - "prompt": "Put a button inside the group at the bottom.", - "expect": "editor-insert-block" - } - ] -} diff --git a/src/experiments/webmcp/index.ts b/src/experiments/webmcp/index.ts index da0a11c31..f0f881a45 100644 --- a/src/experiments/webmcp/index.ts +++ b/src/experiments/webmcp/index.ts @@ -4,8 +4,7 @@ * Collects page tools from the registry, runs them through the * `wpai.webmcp.tools` filter, and registers each one on * `document.modelContext` with one `registerTool` call, up to the per-page - * cap. The built-in editor tools register themselves when the editor stores - * exist on the page. + * cap. The built-in editor tools register once the block editor has initialized. * * `modelContext` lives on `document` (with `navigator` kept as a fallback * for older builds) and, in ChatGPT's browser, is a frozen object that @@ -17,6 +16,7 @@ /** * WordPress dependencies */ +import { subscribe } from '@wordpress/data'; import domReady from '@wordpress/dom-ready'; import { applyFilters } from '@wordpress/hooks'; @@ -144,6 +144,21 @@ const registry: WebMCPRegistry = { window.wpai = window.wpai ?? {}; window.wpai.webmcp = registry; -domReady( register ); +domReady( () => { + register(); + if ( + document.body.classList.contains( 'block-editor-page' ) && + ! hasEditor() + ) { + // The editor may load its post after DOM ready. Stop listening once + // it is initialized, so later edits do not register tools again. + const unsubscribe = subscribe( () => { + if ( hasEditor() ) { + unsubscribe(); + register(); + } + } ); + } +} ); export {}; diff --git a/tests/e2e-testing/e2e-testing.php b/tests/e2e-testing/e2e-testing.php index d62b279b3..52952ad4b 100644 --- a/tests/e2e-testing/e2e-testing.php +++ b/tests/e2e-testing/e2e-testing.php @@ -27,6 +27,24 @@ add_action( 'init', 'ai_e2e_register_sample_post_type', 5 ); add_action( 'init', 'ai_e2e_seed_sample_post', 20 ); +// Register a classic editor screen for the WebMCP availability regression test. +add_action( 'init', 'ai_e2e_register_classic_post_type' ); + +/** + * Registers a post type that uses the classic editor because it has no REST support. + */ +function ai_e2e_register_classic_post_type() { + register_post_type( + 'ai_e2e_classic', + array( + 'label' => 'AI E2E Classic', + 'public' => true, + 'show_in_rest' => false, + 'supports' => array( 'title', 'editor' ), + ) + ); +} + /** * Registers REST endpoints for seeding and clearing dummy AI provider credentials. * diff --git a/tests/e2e/specs/experiments/webmcp.spec.js b/tests/e2e/specs/experiments/webmcp.spec.js index 9ccc5b603..60713f7d2 100644 --- a/tests/e2e/specs/experiments/webmcp.spec.js +++ b/tests/e2e/specs/experiments/webmcp.spec.js @@ -205,6 +205,211 @@ test.describe( 'WebMCP experiment', () => { expect( refusal ).toContain( 'cannot be inserted here' ); } ); + test( 'refuses editing, moving and removing locked blocks without changing the document', async ( { + admin, + page, + } ) => { + await admin.createNewPost( { title: 'Locked blocks' } ); + await page.waitForFunction( () => window.__webmcpTools.length > 0 ); + const locked = await callTool( page, 'editor-insert-block', { + attributes: { + content: 'Keep this text', + lock: { edit: true, move: true, remove: true }, + }, + } ); + const after = await callTool( page, 'editor-insert-block', { + attributes: { content: 'After' }, + } ); + const before = await callTool( page, 'editor-get-document', {} ); + for ( const [ name, input, message ] of [ + [ + 'editor-update-block-text', + { content: 'Changed' }, + 'locked against editing', + ], + [ + 'editor-update-block-attributes', + { attributes: { content: 'Changed', lock: {} } }, + 'locked against editing', + ], + [ + 'editor-move-block', + { afterClientId: after.clientId }, + 'locked against moving', + ], + [ 'editor-remove-block', {}, 'locked against removal' ], + ] ) { + await expect( + callTool( page, name, { clientId: locked.clientId, ...input } ) + ).rejects.toThrow( message ); + expect( await callTool( page, 'editor-get-document', {} ) ).toEqual( + before + ); + } + } ); + + test( 'moving a block after itself changes nothing and adds no undo step', async ( { + admin, + page, + } ) => { + await admin.createNewPost( { title: 'No-op move' } ); + await page.waitForFunction( () => window.__webmcpTools.length > 0 ); + const first = await callTool( page, 'editor-insert-block', { + attributes: { content: 'First' }, + } ); + await callTool( page, 'editor-insert-block', { + attributes: { content: 'Second' }, + } ); + const before = await callTool( page, 'editor-get-document', {} ); + expect( + await callTool( page, 'editor-move-block', { + clientId: first.clientId, + afterClientId: first.clientId, + } ) + ).toMatchObject( { moved: false, message: 'Nothing to move.' } ); + expect( await callTool( page, 'editor-get-document', {} ) ).toEqual( + before + ); + await callTool( page, 'editor-undo', {} ); + await expect + .poll( async () => + ( + await callTool( page, 'editor-get-document', {} ) + ).blocks.map( ( block ) => block.text ) + ) + .toEqual( [ 'First' ] ); + } ); + + test( 'refuses moving a container into itself or a descendant, including after a nested block', async ( { + admin, + page, + } ) => { + await admin.createNewPost( { title: 'Nested moves' } ); + await page.waitForFunction( () => window.__webmcpTools.length > 0 ); + const group = await callTool( page, 'editor-insert-block', { + blockName: 'core/group', + } ); + const child = await callTool( page, 'editor-insert-block', { + blockName: 'core/group', + parentClientId: group.clientId, + } ); + const grandchild = await callTool( page, 'editor-insert-block', { + blockName: 'core/group', + parentClientId: child.clientId, + } ); + const before = await callTool( page, 'editor-get-document', {} ); + for ( const input of [ + { parentClientId: group.clientId }, + { parentClientId: child.clientId }, + { parentClientId: grandchild.clientId }, + { afterClientId: child.clientId }, + { afterClientId: grandchild.clientId }, + ] ) { + await expect( + callTool( page, 'editor-move-block', { + clientId: group.clientId, + ...input, + } ) + ).rejects.toThrow( 'cannot be moved into itself' ); + expect( await callTool( page, 'editor-get-document', {} ) ).toEqual( + before + ); + } + } ); + + test( 'refuses the text shortcut for quote and pullquote without changing their content', async ( { + admin, + page, + } ) => { + await admin.createNewPost( { title: 'Quote attributes' } ); + await page.waitForFunction( () => window.__webmcpTools.length > 0 ); + for ( const blockName of [ 'core/quote', 'core/pullquote' ] ) { + const block = await callTool( page, 'editor-insert-block', { + blockName, + } ); + const before = await callTool( page, 'editor-get-document', {} ); + await expect( + callTool( page, 'editor-update-block-text', { + clientId: block.clientId, + content: 'Wrong attribute', + } ) + ).rejects.toThrow( 'does not use a content attribute' ); + expect( await callTool( page, 'editor-get-document', {} ) ).toEqual( + before + ); + } + } ); + + test( 'reports failed saves and publishes instead of returning success', async ( { + admin, + page, + } ) => { + await admin.createNewPost( { title: 'Save failure' } ); + await page.waitForFunction( () => window.__webmcpTools.length > 0 ); + const saved = await callTool( page, 'editor-save', {} ); + expect( saved.saving ).toBe( false ); + await page.route( + /(?:\/|%2F)wp(?:\/|%2F)v2(?:\/|%2F)posts(?:\/|%2F)\d+/i, + async ( route ) => { + if ( route.request().method() !== 'POST' ) { + await route.continue(); + return; + } + await route.fulfill( { + status: 500, + contentType: 'application/json', + body: JSON.stringify( { + code: 'webmcp_test_failure', + message: 'Save refused for testing', + data: { status: 500 }, + } ), + } ); + } + ); + await callTool( page, 'editor-set-title', { title: 'Unsaved change' } ); + for ( const name of [ 'editor-save', 'editor-publish' ] ) { + await expect( callTool( page, name, {} ) ).rejects.toThrow( + 'could not be saved' + ); + expect( + await page.evaluate( () => ( { + failed: window.wp.data + .select( 'core/editor' ) + .didPostSaveRequestFail(), + saving: window.wp.data + .select( 'core/editor' ) + .isSavingPost(), + status: window.wp.data + .select( 'core/editor' ) + .getCurrentPostAttribute( 'status' ), + } ) ) + ).toEqual( { failed: true, saving: false, status: 'draft' } ); + } + } ); + + test( 'lists no tools in the classic editor even though the editor store scripts are loaded', async ( { + admin, + page, + } ) => { + await admin.visitAdminPage( + 'post-new.php', + 'post_type=ai_e2e_classic' + ); + await expect( page.locator( '#postdivrich' ) ).toBeVisible(); + const result = await page.evaluate( () => ( { + hasEditorSelector: + typeof window.wp.data.select( 'core/editor' ) + .getCurrentPostId === 'function', + listed: window.wpai.webmcp.getTools().map( ( tool ) => tool.name ), + registered: window.__webmcpTools.map( ( tool ) => tool.name ), + } ) ); + expect( result ).toEqual( { + hasEditorSelector: true, + listed: [], + registered: [], + } ); + } ); + test( 'does not load the bridge outside the editor', async ( { admin, page, diff --git a/tools/webmcp-evals.mjs b/tools/webmcp-evals.mjs deleted file mode 100644 index 6f6d94008..000000000 --- a/tools/webmcp-evals.mjs +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Runs the WebMCP tool-selection evals against an OpenAI-compatible chat - * endpoint and reports the prompts for which the model picked the wrong tool. - * - * WEBMCP_EVAL_ENDPOINT=https://api.openai.com/v1/chat/completions \ - * WEBMCP_EVAL_API_KEY=... WEBMCP_EVAL_MODEL=gpt-4o-mini \ - * node tools/webmcp-evals.mjs - * - * The tool list comes from the bridge's own definitions, so a change to a - * description is judged by whether a model still chooses the right tool. - */ -/* eslint-disable no-console */ - -/** - * External dependencies - */ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -const here = path.dirname( fileURLToPath( import.meta.url ) ); -const evals = JSON.parse( - readFileSync( - path.join( here, '../src/experiments/webmcp/evals.json' ), - 'utf8' - ) -); -const { EDITOR_TOOL_DEFINITIONS } = await import( - path.join( here, '../src/experiments/webmcp/editor-tool-definitions.mjs' ) -); - -const { - WEBMCP_EVAL_ENDPOINT: endpoint, - WEBMCP_EVAL_API_KEY: apiKey, - WEBMCP_EVAL_MODEL: model, -} = process.env; -if ( ! endpoint || ! apiKey || ! model ) { - console.error( - 'Set WEBMCP_EVAL_ENDPOINT, WEBMCP_EVAL_API_KEY and WEBMCP_EVAL_MODEL.' - ); - process.exit( 2 ); -} - -const tools = EDITOR_TOOL_DEFINITIONS.map( - /** @param {{ name: string, description: string, inputSchema: object }} tool */ - ( tool ) => ( { - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: tool.inputSchema, - }, - } ) -); - -let failures = 0; -for ( const { prompt, expect } of evals.cases ) { - const response = await fetch( endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${ apiKey }`, - }, - body: JSON.stringify( { - model, - messages: [ - { - role: 'system', - content: - 'You are working inside the WordPress block editor on the current page. Use the tools to act on the page.', - }, - { role: 'user', content: prompt }, - ], - tools, - tool_choice: 'required', - } ), - } ); - const json = await response.json(); - const picked = - json?.choices?.[ 0 ]?.message?.tool_calls?.[ 0 ]?.function?.name ?? - '(none)'; - const ok = picked === expect; - if ( ! ok ) { - failures++; - } - console.log( - `${ ok ? 'ok ' : 'FAIL' } ${ expect.padEnd( - 32 - ) } got ${ picked.padEnd( 32 ) } ${ prompt }` - ); -} -console.log( - `\n${ evals.cases.length - failures }/${ - evals.cases.length - } picked the expected tool.` -); -process.exit( failures ? 1 : 0 ); From 0f33ba43272f17653efe44afdbe76e9c7fb52588 Mon Sep 17 00:00:00 2001 From: Mihai Dragomirescu Date: Wed, 7 Oct 2026 14:06:19 +0300 Subject: [PATCH 6/8] WebMCP: stricter publish, transform, duplicate and lock handling - editor-publish checks the post can be saved before setting the status, and puts the previous status back if the save fails. - editor-transform-block refuses blocks locked against removal and transforms the block does not offer. - editor-duplicate-block throws when nothing was created. - editor-update-block-attributes refuses changes to the lock attribute. - The screens filter falls back to the defaults when it returns a non-array. - Bridge data goes through localize_script; the bridge reads maxTools as a number. --- includes/Experiments/WebMCP/WebMCP.php | 14 +++- src/experiments/webmcp/editor-tools.ts | 51 ++++++++++++- src/experiments/webmcp/index.ts | 6 +- .../Experiments/WebMCP/WebMCPTest.php | 21 ++++-- tests/e2e/specs/experiments/webmcp.spec.js | 75 ++++++++++++++++++- 5 files changed, 154 insertions(+), 13 deletions(-) diff --git a/includes/Experiments/WebMCP/WebMCP.php b/includes/Experiments/WebMCP/WebMCP.php index 58058a612..dd7a36599 100644 --- a/includes/Experiments/WebMCP/WebMCP.php +++ b/includes/Experiments/WebMCP/WebMCP.php @@ -100,7 +100,14 @@ public function get_screens(): array { * * @param list $screens Hook suffixes. Default the post editor screens. */ - $screens = apply_filters( 'wpai_webmcp_screens', array( 'post.php', 'post-new.php' ) ); + $defaults = array( 'post.php', 'post-new.php' ); + $screens = apply_filters( 'wpai_webmcp_screens', $defaults ); + + // A filter that returns something other than an array is a mistake; + // keep the default screens rather than fail on every admin page. + if ( ! is_array( $screens ) ) { + return $defaults; + } return array_values( array_filter( $screens, 'is_string' ) ); } @@ -137,13 +144,14 @@ public function enqueue_assets( string $hook_suffix ): void { return; } - Asset_Loader::add_global_data( + Asset_Loader::enqueue_script( self::SCRIPT_HANDLE, 'experiments/webmcp' ); + Asset_Loader::localize_script( + self::SCRIPT_HANDLE, 'WebMCP', array( 'screen' => $hook_suffix, 'maxTools' => $this->get_max_tools(), ) ); - Asset_Loader::enqueue_script( self::SCRIPT_HANDLE, 'experiments/webmcp' ); } } diff --git a/src/experiments/webmcp/editor-tools.ts b/src/experiments/webmcp/editor-tools.ts index 547c2d45d..99f2a4fd3 100644 --- a/src/experiments/webmcp/editor-tools.ts +++ b/src/experiments/webmcp/editor-tools.ts @@ -6,6 +6,7 @@ import { createBlock, getBlockType, getBlockTypes, + getPossibleBlockTransformations, switchToBlockType, } from '@wordpress/blocks'; import { dispatch, select } from '@wordpress/data'; @@ -191,7 +192,8 @@ const document = () => { }; }; -const save = async () => { +/** Throws unless the post can be saved right now. */ +const assertSaveable = () => { if ( editorSelect().isSavingPost() ) { throw new Error( 'A post save is already in progress. Try again after it finishes.' @@ -202,6 +204,10 @@ const save = async () => { 'The post cannot be saved yet. Add a title or content first.' ); } +}; + +const save = async () => { + assertSaveable(); await editorDispatch().savePost(); const editor = editorSelect(); if ( editor.isSavingPost() ) { @@ -294,6 +300,13 @@ const implementations: Record< if ( Object.keys( attributes ).length === 0 ) { throw new Error( 'attributes must have at least one key.' ); } + // Changing the lock here would let a second call remove or move a + // block the editor has locked, so locks stay with the person. + if ( Object.prototype.hasOwnProperty.call( attributes, 'lock' ) ) { + throw new Error( + 'The lock attribute cannot be changed by an agent. Ask the person to change the lock in the editor.' + ); + } blocksDispatch().updateBlockAttributes( block.clientId, attributes ); blocksDispatch().selectBlock( block.clientId ); return { clientId: block.clientId, attributes }; @@ -389,12 +402,37 @@ const implementations: Record< const created = await blocksDispatch().duplicateBlocks( [ block.clientId, ] ); - return { duplicated: block.clientId, clientIds: created ?? [] }; + if ( ! Array.isArray( created ) || created.length === 0 ) { + throw new Error( + `${ block.name } was not duplicated. It may be locked, or its parent may not allow another copy.` + ); + } + return { duplicated: block.clientId, clientIds: created }; }, 'editor-transform-block': ( input ) => { const block = requireBlock( input.clientId ); const target = asString( input.blockName, 'blockName' ); + // A transform replaces the block, so it needs the same permission as + // removing it. + if ( ! blocksSelect().canRemoveBlock( block.clientId ) ) { + throw new Error( 'This block is locked against removal.' ); + } + const possible = getPossibleBlockTransformations( [ + block as unknown as Parameters< + typeof getPossibleBlockTransformations + >[ 0 ][ number ], + ] ) as Array< { name: string } >; + if ( ! possible.some( ( type ) => type.name === target ) ) { + throw new Error( + `${ + block.name + } cannot be transformed into ${ target }. It can become: ${ + possible.map( ( type ) => type.name ).join( ', ' ) || + 'nothing' + }.` + ); + } const transformed = switchToBlockType( block as unknown as Parameters< typeof switchToBlockType >[ 0 ], target @@ -477,8 +515,15 @@ const implementations: Record< 'editor-save': () => save(), 'editor-publish': async () => { + // Check before touching the status: if the save then failed, a + // status left on publish would publish the post at the next draft save. + assertSaveable(); + const previous = editorSelect().getEditedPostAttribute( 'status' ); editorDispatch().editPost( { status: 'publish' } ); - return save(); + return save().catch( ( error: unknown ) => { + editorDispatch().editPost( { status: previous } ); + throw error; + } ); }, }; diff --git a/src/experiments/webmcp/index.ts b/src/experiments/webmcp/index.ts index f0f881a45..ec1ae71fd 100644 --- a/src/experiments/webmcp/index.ts +++ b/src/experiments/webmcp/index.ts @@ -54,7 +54,11 @@ const getModelContext = (): ModelContext | null => { return null; }; -const data: BridgeData = window.aiWebMCP ?? { screen: '', maxTools: 30 }; +// wp_localize_script turns numbers into strings, so read maxTools back as one. +const data: BridgeData = { + screen: String( window.aiWebMCP?.screen ?? '' ), + maxTools: Math.max( 1, Number( window.aiWebMCP?.maxTools ) || 30 ), +}; const custom: WebMCPTool[] = []; const registeredNames = new Set< string >(); diff --git a/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php b/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php index 287a7deb8..3c17b7e18 100644 --- a/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php +++ b/tests/Integration/Includes/Experiments/WebMCP/WebMCPTest.php @@ -120,6 +120,17 @@ static function ( array $screens ) { $this->assertSame( array( 'post.php', 'post-new.php', 'site-editor.php' ), $experiment->get_screens() ); } + /** + * Tests that a filter returning a non-array keeps the default screens. + */ + public function test_screens_fall_back_to_the_default_when_a_filter_returns_a_non_array(): void { + $experiment = new WebMCP(); + + add_filter( 'wpai_webmcp_screens', static fn() => 'post.php' ); + + $this->assertSame( array( 'post.php', 'post-new.php' ), $experiment->get_screens() ); + } + /** * Tests the cap and its filter. */ @@ -157,12 +168,12 @@ public function test_bridge_enqueued_on_the_editor_with_data(): void { $this->assertTrue( wp_script_is( 'ai_webmcp', 'enqueued' ) ); - $inline = wp_scripts()->get_data( 'ai_webmcp', 'before' ); - $this->assertIsArray( $inline ); - $printed = str_replace( '\\/', '/', implode( "\n", array_filter( $inline, 'is_string' ) ) ); - $this->assertStringContainsString( 'window.aiWebMCP=', $printed ); + $localized = wp_scripts()->get_data( 'ai_webmcp', 'data' ); + $this->assertIsString( $localized ); + $printed = str_replace( '\\/', '/', $localized ); + $this->assertStringContainsString( 'var aiWebMCP = ', $printed ); $this->assertStringContainsString( '"screen":"post.php"', $printed ); - $this->assertStringContainsString( '"maxTools":30', $printed ); + $this->assertStringContainsString( '"maxTools":"30"', $printed ); } /** diff --git a/tests/e2e/specs/experiments/webmcp.spec.js b/tests/e2e/specs/experiments/webmcp.spec.js index 60713f7d2..e196d27f3 100644 --- a/tests/e2e/specs/experiments/webmcp.spec.js +++ b/tests/e2e/specs/experiments/webmcp.spec.js @@ -382,8 +382,81 @@ test.describe( 'WebMCP experiment', () => { status: window.wp.data .select( 'core/editor' ) .getCurrentPostAttribute( 'status' ), + editedStatus: window.wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'status' ), } ) ) - ).toEqual( { failed: true, saving: false, status: 'draft' } ); + ).toEqual( { + failed: true, + saving: false, + status: 'draft', + editedStatus: 'draft', + } ); + } + } ); + + test( 'refuses to publish a post that cannot be saved, without changing its status', async ( { + admin, + page, + } ) => { + await admin.createNewPost(); + await page.waitForFunction( () => window.__webmcpTools.length > 0 ); + await expect( callTool( page, 'editor-publish', {} ) ).rejects.toThrow( + 'cannot be saved yet' + ); + expect( + await page.evaluate( () => + window.wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'status' ) + ) + ).toBe( 'auto-draft' ); + } ); + + test( 'refuses lock changes, unsupported or locked transforms and duplicates that create nothing', async ( { + admin, + page, + } ) => { + await admin.createNewPost( { title: 'Strict structure' } ); + await page.waitForFunction( () => window.__webmcpTools.length > 0 ); + const open = await callTool( page, 'editor-insert-block', { + attributes: { content: 'Open' }, + } ); + const pinned = await callTool( page, 'editor-insert-block', { + attributes: { content: 'Pinned', lock: { remove: true } }, + } ); + const more = await callTool( page, 'editor-insert-block', { + blockName: 'core/more', + } ); + const before = await callTool( page, 'editor-get-document', {} ); + for ( const [ name, input, message ] of [ + [ + 'editor-update-block-attributes', + { clientId: pinned.clientId, attributes: { lock: {} } }, + 'lock attribute cannot be changed', + ], + [ + 'editor-transform-block', + { clientId: open.clientId, blockName: 'core/image' }, + 'cannot be transformed into core/image', + ], + [ + 'editor-transform-block', + { clientId: pinned.clientId, blockName: 'core/heading' }, + 'locked against removal', + ], + [ + 'editor-duplicate-block', + { clientId: more.clientId }, + 'was not duplicated', + ], + ] ) { + await expect( callTool( page, name, input ) ).rejects.toThrow( + message + ); + expect( await callTool( page, 'editor-get-document', {} ) ).toEqual( + before + ); } } ); From da009a7c3e8b0300f08a37ee489f28dbe767a875 Mon Sep 17 00:00:00 2001 From: Mihai Dragomirescu Date: Thu, 8 Oct 2026 14:09:44 +0300 Subject: [PATCH 7/8] WebMCP: respect post and save locks, reject templateLock, catch partial saves - Saving and publishing are refused while the post is locked by another user (isPostLocked) or saving is locked (isPostSavingLocked). - editor-update-block-attributes rejects templateLock as well as lock. - A save that reports no failure but leaves the post dirty is reported as a failure (isEditedPostDirty). --- src/experiments/webmcp/editor-tools.ts | 33 ++++++++++++++++++---- tests/e2e/specs/experiments/webmcp.spec.js | 33 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/experiments/webmcp/editor-tools.ts b/src/experiments/webmcp/editor-tools.ts index 99f2a4fd3..96d0daca1 100644 --- a/src/experiments/webmcp/editor-tools.ts +++ b/src/experiments/webmcp/editor-tools.ts @@ -119,6 +119,9 @@ interface EditorSelectors { isSavingPost: () => boolean; didPostSaveRequestFail: () => boolean; isEditedPostSaveable: () => boolean; + isEditedPostDirty: () => boolean; + isPostLocked: () => boolean; + isPostSavingLocked: () => boolean; } interface EditorActions { editPost: ( edits: Record< string, unknown > ) => unknown; @@ -194,6 +197,16 @@ const document = () => { /** Throws unless the post can be saved right now. */ const assertSaveable = () => { + if ( editorSelect().isPostLocked() ) { + throw new Error( + 'Another user is editing this post, so it cannot be saved from here.' + ); + } + if ( editorSelect().isPostSavingLocked() ) { + throw new Error( + 'Saving is locked in the editor right now, usually by a plugin waiting on something. Check the editor before trying again.' + ); + } if ( editorSelect().isSavingPost() ) { throw new Error( 'A post save is already in progress. Try again after it finishes.' @@ -220,6 +233,13 @@ const save = async () => { 'The post could not be saved. Check the error in the editor and try again.' ); } + // A save that reports no failure but leaves changes behind did not save + // everything; say so rather than report success. + if ( editor.isEditedPostDirty() ) { + throw new Error( + 'The post still has unsaved changes after the save. Check the editor before trying again.' + ); + } return { postId: editor.getCurrentPostId(), status: editor.getEditedPostAttribute( 'status' ), @@ -301,11 +321,14 @@ const implementations: Record< throw new Error( 'attributes must have at least one key.' ); } // Changing the lock here would let a second call remove or move a - // block the editor has locked, so locks stay with the person. - if ( Object.prototype.hasOwnProperty.call( attributes, 'lock' ) ) { - throw new Error( - 'The lock attribute cannot be changed by an agent. Ask the person to change the lock in the editor.' - ); + // block the editor has locked, so locks stay with the person. The same + // goes for templateLock, which locks a container's inner blocks. + for ( const key of [ 'lock', 'templateLock' ] ) { + if ( Object.prototype.hasOwnProperty.call( attributes, key ) ) { + throw new Error( + `The ${ key } attribute cannot be changed by an agent. Ask the person to change it in the editor.` + ); + } } blocksDispatch().updateBlockAttributes( block.clientId, attributes ); blocksDispatch().selectBlock( block.clientId ); diff --git a/tests/e2e/specs/experiments/webmcp.spec.js b/tests/e2e/specs/experiments/webmcp.spec.js index e196d27f3..ffc5a0241 100644 --- a/tests/e2e/specs/experiments/webmcp.spec.js +++ b/tests/e2e/specs/experiments/webmcp.spec.js @@ -395,6 +395,31 @@ test.describe( 'WebMCP experiment', () => { } } ); + test( 'refuses to save while saving is locked, and saves once the lock is gone', async ( { + admin, + page, + } ) => { + await admin.createNewPost( { title: 'Saving locked' } ); + await page.waitForFunction( () => window.__webmcpTools.length > 0 ); + await page.evaluate( () => + window.wp.data + .dispatch( 'core/editor' ) + .lockPostSaving( 'webmcp-e2e' ) + ); + for ( const name of [ 'editor-save', 'editor-publish' ] ) { + await expect( callTool( page, name, {} ) ).rejects.toThrow( + 'Saving is locked' + ); + } + await page.evaluate( () => + window.wp.data + .dispatch( 'core/editor' ) + .unlockPostSaving( 'webmcp-e2e' ) + ); + const saved = await callTool( page, 'editor-save', {} ); + expect( saved.status ).toBe( 'draft' ); + } ); + test( 'refuses to publish a post that cannot be saved, without changing its status', async ( { admin, page, @@ -435,6 +460,14 @@ test.describe( 'WebMCP experiment', () => { { clientId: pinned.clientId, attributes: { lock: {} } }, 'lock attribute cannot be changed', ], + [ + 'editor-update-block-attributes', + { + clientId: open.clientId, + attributes: { templateLock: 'all' }, + }, + 'templateLock attribute cannot be changed', + ], [ 'editor-transform-block', { clientId: open.clientId, blockName: 'core/image' }, From 07892d835d1e77b77ba2b34c397c11429c5daebd Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 8 Oct 2026 11:18:05 -0600 Subject: [PATCH 8/8] Add more robust checks that a block can be edited. When transforming or duplicating blocks, ensure that actually worked before we send a success response. Don't rollback the post status if save worked but still threw an error --- src/experiments/webmcp/editor-tools.ts | 97 ++++++++++++++-- tests/e2e/specs/experiments/webmcp.spec.js | 127 +++++++++++++++++++++ 2 files changed, 216 insertions(+), 8 deletions(-) diff --git a/src/experiments/webmcp/editor-tools.ts b/src/experiments/webmcp/editor-tools.ts index 96d0daca1..edeb22890 100644 --- a/src/experiments/webmcp/editor-tools.ts +++ b/src/experiments/webmcp/editor-tools.ts @@ -116,6 +116,7 @@ interface EditorSelectors { getCurrentPostId: () => number | null; getCurrentPostType: () => string; getEditedPostAttribute: ( attribute: string ) => unknown; + getCurrentPostAttribute: ( attribute: string ) => unknown; isSavingPost: () => boolean; didPostSaveRequestFail: () => boolean; isEditedPostSaveable: () => boolean; @@ -136,6 +137,9 @@ interface BlockEditorSelectors { getBlockOrder: ( rootClientId?: string ) => string[]; canInsertBlockType: ( name: string, rootClientId?: string ) => boolean; canEditBlock: ( clientId: string ) => boolean; + getBlockEditingMode: ( + clientId: string + ) => 'default' | 'contentOnly' | 'disabled'; canMoveBlock: ( clientId: string ) => boolean; canRemoveBlock: ( clientId: string ) => boolean; getBlockParents: ( clientId: string ) => string[]; @@ -184,6 +188,43 @@ const requireBlock = ( clientId: unknown ): EditorBlock => { return block; }; +/** + * Throws unless the editor lets the person change these attributes. + */ +const assertCanEdit = ( block: EditorBlock, keys: string[] ) => { + if ( ! blocksSelect().canEditBlock( block.clientId ) ) { + throw new Error( 'This block is locked against editing.' ); + } + + const mode = blocksSelect().getBlockEditingMode( block.clientId ); + if ( mode === 'disabled' ) { + throw new Error( 'This block cannot be edited here.' ); + } + + if ( mode === 'contentOnly' ) { + const definitions = ( getBlockType( block.name )?.attributes ?? + {} ) as Record< + string, + { role?: unknown; __experimentalRole?: unknown } + >; + const blocked = keys.filter( ( key ) => { + const definition = definitions[ key ]; + return ( + definition?.role !== 'content' && + definition?.__experimentalRole !== 'content' + ); + } ); + + if ( blocked.length > 0 ) { + throw new Error( + `Only this block's content can be edited here, not: ${ blocked.join( + ', ' + ) }.` + ); + } + } +}; + const document = () => { const editor = editorSelect(); return { @@ -297,9 +338,7 @@ const implementations: Record< 'editor-update-block-text': ( input ) => { const block = requireBlock( input.clientId ); - if ( ! blocksSelect().canEditBlock( block.clientId ) ) { - throw new Error( 'This block is locked against editing.' ); - } + assertCanEdit( block, [ 'content' ] ); if ( ! TEXT_BLOCKS.has( block.name ) ) { throw new Error( `${ block.name } does not use a content attribute. Edit its inner text blocks or use editor-update-block-attributes with its attribute schema.` @@ -313,13 +352,11 @@ const implementations: Record< 'editor-update-block-attributes': ( input ) => { const block = requireBlock( input.clientId ); - if ( ! blocksSelect().canEditBlock( block.clientId ) ) { - throw new Error( 'This block is locked against editing.' ); - } const attributes = asObject( input.attributes ); if ( Object.keys( attributes ).length === 0 ) { throw new Error( 'attributes must have at least one key.' ); } + assertCanEdit( block, Object.keys( attributes ) ); // Changing the lock here would let a second call remove or move a // block the editor has locked, so locks stay with the person. The same // goes for templateLock, which locks a container's inner blocks. @@ -422,10 +459,13 @@ const implementations: Record< 'editor-duplicate-block': async ( input ) => { const block = requireBlock( input.clientId ); - const created = await blocksDispatch().duplicateBlocks( [ + const returned = await blocksDispatch().duplicateBlocks( [ block.clientId, ] ); - if ( ! Array.isArray( created ) || created.length === 0 ) { + const created = ( Array.isArray( returned ) ? returned : [] ).filter( + ( id ) => blocksSelect().getBlock( id ) + ); + if ( created.length === 0 ) { throw new Error( `${ block.name } was not duplicated. It may be locked, or its parent may not allow another copy.` ); @@ -465,7 +505,31 @@ const implementations: Record< `${ block.name } cannot be transformed into ${ target }.` ); } + const root = blocksSelect().getBlockRootClientId( block.clientId ); + const refused = transformed.find( + ( item ) => + ! blocksSelect().canInsertBlockType( + item.name, + root || undefined + ) + ); + if ( refused ) { + throw new Error( + `${ refused.name } is not allowed here, so ${ block.name } cannot be transformed into ${ target }.` + ); + } blocksDispatch().replaceBlocks( block.clientId, transformed ); + + if ( + blocksSelect().getBlock( block.clientId ) || + ! transformed.every( ( item ) => + blocksSelect().getBlock( item.clientId ) + ) + ) { + throw new Error( + `${ block.name } was not transformed into ${ target }. The editor refused the change.` + ); + } const first = transformed[ 0 ]; if ( first ) { blocksDispatch().selectBlock( first.clientId ); @@ -544,6 +608,23 @@ const implementations: Record< const previous = editorSelect().getEditedPostAttribute( 'status' ); editorDispatch().editPost( { status: 'publish' } ); return save().catch( ( error: unknown ) => { + // Check if save worked but still threw an error. + // If so, we don't want to roll back the status. + const editor = editorSelect(); + const saved = editor.getCurrentPostAttribute( 'status' ); + if ( + ! editor.didPostSaveRequestFail() && + ( saved === 'publish' || saved === 'future' ) + ) { + return { + postId: editor.getCurrentPostId(), + status: saved, + warning: + error instanceof Error + ? error.message + : String( error ), + }; + } editorDispatch().editPost( { status: previous } ); throw error; } ); diff --git a/tests/e2e/specs/experiments/webmcp.spec.js b/tests/e2e/specs/experiments/webmcp.spec.js index ffc5a0241..3f2da18b4 100644 --- a/tests/e2e/specs/experiments/webmcp.spec.js +++ b/tests/e2e/specs/experiments/webmcp.spec.js @@ -493,6 +493,133 @@ test.describe( 'WebMCP experiment', () => { } } ); + test( 'refuses transforms and duplicates the parent does not allow, and non-content edits in contentOnly blocks', async ( { + admin, + page, + } ) => { + await admin.createNewPost( { title: 'Restricted parents' } ); + await page.waitForFunction( () => window.__webmcpTools.length > 0 ); + const ids = await page.evaluate( () => { + const { createBlock } = window.wp.blocks; + const { dispatch } = window.wp.data; + const paragraph = ( content ) => + createBlock( 'core/paragraph', { content } ); + const allowed = paragraph( 'Only paragraphs here' ); + const inserted = paragraph( 'No inserts here' ); + const content = paragraph( 'Content only' ); + dispatch( 'core/block-editor' ).insertBlocks( [ + createBlock( + 'core/group', + { allowedBlocks: [ 'core/paragraph' ] }, + [ allowed ] + ), + createBlock( 'core/group', { templateLock: 'insert' }, [ + inserted, + ] ), + createBlock( 'core/group', { templateLock: 'contentOnly' }, [ + content, + ] ), + ] ); + return { + allowed: allowed.clientId, + inserted: inserted.clientId, + content: content.clientId, + }; + } ); + // The groups apply their rules once they render. + await page.waitForFunction( + ( [ allowed, content ] ) => { + const blocks = window.wp.data.select( 'core/block-editor' ); + return ( + ! blocks.canInsertBlockType( + 'core/heading', + blocks.getBlockRootClientId( allowed ) + ) && blocks.getBlockEditingMode( content ) === 'contentOnly' + ); + }, + [ ids.allowed, ids.content ] + ); + + const before = await callTool( page, 'editor-get-document', {} ); + for ( const [ name, input, message ] of [ + [ + 'editor-transform-block', + { clientId: ids.allowed, blockName: 'core/heading' }, + 'core/heading is not allowed here', + ], + [ + 'editor-duplicate-block', + { clientId: ids.inserted }, + 'was not duplicated', + ], + [ + 'editor-update-block-attributes', + { clientId: ids.content, attributes: { className: 'x' } }, + 'not: className', + ], + ] ) { + await expect( callTool( page, name, input ) ).rejects.toThrow( + message + ); + expect( await callTool( page, 'editor-get-document', {} ) ).toEqual( + before + ); + } + + // Content attributes are still editable in a contentOnly block. + await callTool( page, 'editor-update-block-text', { + clientId: ids.content, + content: 'Changed content', + } ); + expect( + await page.evaluate( + ( id ) => + String( + window.wp.data + .select( 'core/block-editor' ) + .getBlock( id ).attributes.content + ), + ids.content + ) + ).toBe( 'Changed content' ); + } ); + + test( 'keeps a post published when it changes again during the publish save', async ( { + admin, + page, + } ) => { + await admin.createNewPost( { title: 'Edited mid-publish' } ); + await page.waitForFunction( () => window.__webmcpTools.length > 0 ); + // Stand in for the person typing while the publish request is out. + await page.route( + /(?:\/|%2F)wp(?:\/|%2F)v2(?:\/|%2F)posts(?:\/|%2F)\d+/i, + async ( route ) => { + if ( route.request().method() === 'POST' ) { + await page.evaluate( () => + window.wp.data + .dispatch( 'core/editor' ) + .editPost( { title: 'Typed during save' } ) + ); + } + await route.continue(); + } + ); + + const published = await callTool( page, 'editor-publish', {} ); + expect( published.status ).toBe( 'publish' ); + expect( published.warning ).toContain( 'unsaved changes' ); + expect( + await page.evaluate( () => ( { + status: window.wp.data + .select( 'core/editor' ) + .getCurrentPostAttribute( 'status' ), + editedStatus: window.wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'status' ), + } ) ) + ).toEqual( { status: 'publish', editedStatus: 'publish' } ); + } ); + test( 'lists no tools in the classic editor even though the editor store scripts are loaded', async ( { admin, page,