Commit 695eb38d4ee for woocommerce
commit 695eb38d4ee21a88265c43bf0a69bee3140b0cf5
Author: Darren Ethier <darren@roughsmootheng.in>
Date: Mon Oct 19 05:33:28 2026 -0400
Track WooCommerce ability calls with a wcadmin_ability_called Tracks event (#69949)
* Track WooCommerce ability calls made over MCP and the Abilities REST API
Records wcadmin_ability_called for each woocommerce/* ability invocation when
usage tracking is enabled. Hooks only collect data in memory; events are sent
once per request on shutdown through the batched Tracks client. Input and
output are never recorded, and the request URL and referrer are dropped from
the event because GET run requests carry input in the query string.
WordPress 7.1+ hooks expose every outcome. On 7.0 only successful executions
fire hooks, so failures are recorded on shutdown as error_code=unknown with no
duration.
* Track direct PHP ability calls as cli, cron or php channels
Kept as a separate commit so it can be dropped: direct callers include other
plugins (for example woocommerce-ai) and may add noise compared with agent
traffic over MCP and REST.
* Use instanceof for the short-circuit sentinel check
is_a() with a string first argument is deprecated in PHP 8.6, and a
short-circuit filter can return a string. The PHPStan WordPress stubs predate
WP_Filter_Sentinel (WordPress 7.1), so the check carries an inline ignore until
#69841 updates them.
* Check tracking consent and capture event context per ability call
Consent is checked when a call starts and again when it is recorded, so a
switch_to_blog() into an opted-out site or a call that disables tracking is not
sent. Tracks properties are built when the call finishes, so user, site and
timestamp belong to the call rather than to shutdown. Long-running WP-CLI
processes, such as an MCP STDIO server, send a full buffer instead of dropping
events.
* Detect MCP requests from standard MCP headers and record REST namespace and auth
MCP detection no longer depends on the MCP Adapter alone: Streamable HTTP
clients send MCP-Protocol-Version (2025-06-18 and later) or Mcp-Session-Id on
every request, whichever server handles them. The adapter filter still covers
STDIO. Calls also record the namespace of the outermost REST route (never the
full route, which can hold IDs) and whether an application password
authenticated the request, so analysis can split traffic without the request
URL. The fallback channel is renamed from php to other, nested REST requests
no longer reset the run-route state, and the run route matches
case-insensitively like core.
* Record only known MCP clients, from request metadata or the adapter session
clientInfo.name is free text chosen by the client, so it is mapped to a short
list of known clients and everything else is recorded as other. The name is
read from the request's _meta, which MCP 2026-07-28 sends with every request
on any server, before falling back to the MCP Adapter session (2025-11-25 and
earlier). It is resolved only when an MCP event is recorded.
* Record ability calls the Abilities REST API rejects before execution
The run endpoint checks the request method, input and permissions before it
calls execute(), so those rejections fired no execution hooks. WooCommerce's
abilities reject a nonexistent order or product ID in their permission check,
so this was a common agent error going unrecorded. Rejections are recorded only
for registered woocommerce/* abilities and when no execution was tracked for
the request.
* Report duration_ms only for ability calls that executed
Validation, permission and short-circuit outcomes were timed from invocation
while successes were timed from execution, so averages mixed two measures.
Calls that never executed now omit the property.
* Keep ability frames matched at the cap and report dropped events
On WordPress 7.0, failed calls keep their frame until shutdown. Once 25 had
piled up, new calls opened no frame and a later success closed a stale frame
instead, reporting a failure as a success with an inflated duration. The
oldest frame is now recorded as an unknown failure to make room. When the
per-request event limit drops events, the last event carries events_dropped.
* Record ability names in full
Abilities have no name length limit, so truncating the normalized name to 64
characters could make two abilities indistinguishable.
* Cover the cron channel, WordPress 7.0 nested failures and is_test in ability tracking tests
* Describe ability call tracking without limiting it to AI agents in the changelog
* Require the stored tracking opt-in for ability tracking
WC_Site_Tracking::is_tracking_enabled() also treats a posted
wc_tracker_checkbox=yes as consent, for the onboarding wizard. Any visitor can
post that to the REST API, which let an anonymous request to the abilities run
route send an event from a store that opted out.
* Attach ability tracking hooks on multisite and check consent per call
Consent is per site, but hooks were attached only when the site handling init
had opted in, so a request that started on an opted-out site and switched to an
opted-in one recorded nothing. Multisite installs now always attach the hooks
and rely on the per-call consent checks. Single sites that opted out still
attach nothing.
* Record how the ability caller authenticated
Replaces uses_app_password with auth: application_password, jetpack, cookie,
other (another REST authentication method) or none (logged out, WP-CLI or
cron). channel=rest alone can't tell a merchant in wp-admin from an external
agent, since both use the same run endpoint. Jetpack is only asked when the
request carries its signature arguments, so its REST authentication isn't
initialized on other requests.
* Skip ability events from logged-out visitors and unexposed abilities
Any visitor could call the abilities run route and have the rejection sent to
Tracks with their IP address and user agent, and trigger the shutdown send.
Web requests without an authenticated caller are no longer recorded; WP-CLI
and cron still are, as auth=none. REST probes of abilities not exposed to REST
(rest_ability_not_found) are not recorded either.
* Keep ability tracking failures out of ability calls and shutdown
Events are built inside core's ability hooks, and building one runs
third-party woocommerce_tracks_event_properties filters, the tracking consent
filters, Jetpack identity code and the MCP Adapter session lookup. A throw
there turned a successful ability call into an error. Event building, the
consent check and the shutdown flush now swallow exceptions, like
WCEmailTemplateSyncTracker does, and the buffer is cleared before sending so a
failed flush can't send events twice.
* Record only slug-like ability error codes
Error codes are normally fixed slugs, but a filter can return a WP_Error whose
code was built from input, and normalizing it would still send that input (an
email address becomes customer_example_com). Codes that aren't slugs are
recorded as other.
* Keep the client and time of ability calls recorded after the fact
On WordPress 7.0, a failed call is only recorded at the next close or at
shutdown. By then the REST request is gone, so a 2026-07-28 MCP client was
recorded as none and cached that way, and the event carried the recording
time. The client is now resolved when the call starts, and calls recorded late
use their start time. Site and user are still read when recorded, which only
differs if the request switched them in between; an inline comment notes it.
The client lookup also no longer lets an MCP Adapter failure break the call.
* Match ability frames by execution state as well as name
On WordPress 7.1, a recursive call of the same ability that failed without a
closing hook stayed on top of the stack and hid its outer call, so the outer
call's result was lost and both were recorded as unknown. Frames are now
matched by execution state too. On WordPress 7.0 such calls can't be told
apart, so their outcomes can swap while counts stay right; a comment notes it.
* Tighten ability tracking tests
Assert the actual REST rejection code, register test routes without re-running
every rest_api_init callback, keep the long-running flush test from reaching
the real pixel sender through a send_queued_pixels() seam, and drop an is_test
assertion that only restated the implementation.
* Record whether an Automattician made the ability call
Adds `is_a11n`, so internal traffic can be told apart from merchants the
way the MCP reports segment on `is_a11n` / `is_test`. It is true for a
WordPress.com Automattician or a request through the Automattic proxy
(`A8C_PROXIED_REQUEST`, or `AT_PROXIED_REQUEST` on Atomic), and false
where neither can be detected, such as self-hosted stores.
* Count local installs as test traffic in ability events
Many local installs report `production` because nothing sets
WP_ENVIRONMENT_TYPE: in one day of WC Tracker snapshots, 1,689 of 1,971
localhost sites and 591 of 1,528 `.local`/`.test`/DDEV/Lando sites did.
`is_test` now also uses Jetpack's `Status::is_local_site()`, which
WooCommerce already ships and WC_Tracker already uses.
* Keep the namespace separable in recorded ability names
Normalizing the whole name turned both `/` and `-` into `_`, so
`woocommerce-ai/orders-list` and `woocommerce/ai-orders-list` were both
recorded as `woocommerce_ai_orders_list`. The namespace and the name are
now normalized separately and joined with `__`, as the WordPress.com MCP
event does (`remote_woocommerce__products_query`).
* Bound the ability-call timestamp assertion by the request window.
A fixed margin after the start time fails when the request itself is slower than that margin.
* Record input-normalization errors and keep test tracking local.
A rejected normalization is an ability failure with a real error code. Unit tests use the same cap-key guard as WC_Tracks::record_event(), and the tracking tests remove only their own hooks.
* Keep sending later ability events when one pixel send throws.
The shutdown flush cleared the queue and then sent it inside one try, so a throw on one event dropped the rest.
* Keep hyphens in tracked ability names.
Replacing a hyphen with an underscore makes products-query and products_query the same event. The slash still becomes a double underscore.
* Track ability calls only from MCP, the Abilities REST API and WP-CLI
Direct PHP calls (a plugin running an ability during a page load or another
REST route) and cron calls are plugin internals, not agent usage, and one
called on every page view would add a Tracks request to each of them.
Abilities run inside an MCP or REST call keep that call's channel, and
WP-CLI stays because agents drive sites with it. Cron is now checked before
WP-CLI, so `wp cron event run` (which defines DOING_CRON) isn't counted as
an agent call.
* Record refused MCP calls and detect MCP from the JSON-RPC body
The MCP Adapter checks permission before it runs a tool and returns
without executing a refused call, so no ability hook fired and refused
calls were never recorded. The ability's own permission check still runs
first, directly or via mcp-adapter/execute-ability, so a refusal with no
open frame during a JSON-RPC `tools/call` is now recorded as an error.
Other permission checks in an MCP request (e.g. while listing) are not.
MCP is also detected from a JSON-RPC `tools/call` body (including a
batch), not only the MCP headers, so 2025-03-26 clients on servers that
send no MCP headers are counted as mcp instead of dropped.
* Finish the response before sending ability events
The shutdown send waited for the Tracks reply, so an agent's request took
about 50 ms longer with tracking on. When a web request has ability events
to send, the tracker now finishes the response first where the server
supports it (fastcgi_finish_request() on PHP-FPM, litespeed_finish_request()
on LiteSpeed), as wp-cron.php and WC_Background_Emailer do, then sends.
Elsewhere, such as Apache with mod_php, nothing changes. WP-CLI is left
alone, since no client is waiting.
On nginx + PHP-FPM with a 50 ms send, tracking added 45.6 ms to the median
response before and 2.7 ms after (50 interleaved requests per arm).
* Finish the response last in shutdown, behind a filter
2bcf55cc7d finished the response at shutdown priority 19, so every shutdown
handler at 20 and later (the Tracks send, DeferredEmailQueue at 100,
third-party work) ran after the client had its response and could race the
agent's next request. The tracker now runs at PHP_INT_MAX: other handlers
finish before the response as before, then the response is finished and the
queued pixels are sent directly, since WC_Tracks_Client's own send at
priority 20 has already run. The woocommerce_ability_tracking_finish_response
filter can keep the response open, and the finish is inside the error
handling like the rest of the flush.
On nginx + PHP-FPM, with a 50 ms shutdown handler at priority 100 and a 50 ms
pixel send: with the previous code, turning tracking on cut 42.8 ms from the
median response because the handler escaped it; now the difference is -5.2 ms
(noise), so the handler is waited for and the send is not.
---------
diff --git a/plugins/woocommerce/changelog/wooai-1468-track-ability-calls b/plugins/woocommerce/changelog/wooai-1468-track-ability-calls
new file mode 100644
index 00000000000..a7f6ca651d8
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1468-track-ability-calls
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+When usage tracking is enabled, record how WooCommerce abilities are called, such as by AI agents, without their inputs or results.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index ff26224defc..29347bf5e64 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -23,6 +23,7 @@ use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\LegacyAssets\LegacySelect2UsageTracker;
use Automattic\WooCommerce\Internal\MCP\McpEndpointRemoval;
use Automattic\WooCommerce\Internal\Abilities\AbilitiesRegistry;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesTracking;
use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermAdmin;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
@@ -413,6 +414,7 @@ final class WooCommerce {
$container->get( CustomerEmailVerification::class );
$container->get( OrderLogsCleanupHelper::class );
$container->get( PaymentSettingsScreen::class );
+ $container->get( AbilitiesTracking::class );
/**
* These classes have a register method for attaching hooks.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
new file mode 100644
index 00000000000..af7a4addc1e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
@@ -0,0 +1,977 @@
+<?php
+/**
+ * AbilitiesTracking class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities;
+
+use Automattic\Jetpack\Status;
+use WP_Error;
+use WP_REST_Request;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Records a Tracks event for each WooCommerce ability invocation.
+ *
+ * Hooks only collect data in memory. Events are sent once per request on shutdown through the batched
+ * Tracks client. Ability input and output are never recorded.
+ *
+ * WordPress 7.0 only fires hooks around successful executions, so failures there are recorded on shutdown
+ * with an `unknown` error code and no duration. WordPress 7.1+ hooks expose the other outcomes. A missing
+ * input schema is returned before those hooks and stays `unknown`.
+ *
+ * @since 11.3.0
+ */
+class AbilitiesTracking {
+
+ /**
+ * Tracks event name, without the `wcadmin_` prefix.
+ *
+ * @var string
+ */
+ private const EVENT_NAME = 'ability_called';
+
+ /**
+ * Only abilities in this namespace are tracked.
+ *
+ * @var string
+ */
+ private const ABILITY_PREFIX = 'woocommerce/';
+
+ /**
+ * Upper bound of tracked invocations per request, to keep memory and the shutdown flush bounded.
+ *
+ * @var int
+ */
+ private const MAX_EVENTS_PER_REQUEST = 25;
+
+ /**
+ * Matches the core Abilities API route that runs an ability.
+ *
+ * @var string
+ */
+ private const ABILITIES_RUN_ROUTE_REGEX = '#^/wp-abilities/v\d+/abilities/.+/run$#i';
+
+ /**
+ * Known MCP clients, as normalized name tokens mapped to the recorded value. Checked in order.
+ *
+ * @var array<string, string>
+ */
+ private const KNOWN_MCP_CLIENTS = array(
+ 'claude_code' => 'claude_code',
+ 'claude' => 'claude',
+ 'codex' => 'codex',
+ 'chatgpt' => 'chatgpt',
+ 'openai' => 'chatgpt',
+ 'cursor' => 'cursor',
+ 'windsurf' => 'windsurf',
+ 'vscode' => 'vscode',
+ 'visual_studio_code' => 'vscode',
+ 'copilot' => 'copilot',
+ 'gemini' => 'gemini',
+ 'cline' => 'cline',
+ 'goose' => 'goose',
+ 'zed' => 'zed',
+ 'mcp_inspector' => 'mcp_inspector',
+ 'mcp_wordpress_remote' => 'mcp_wordpress_remote',
+ );
+
+ /**
+ * Value sent for properties that don't apply or are unknown.
+ *
+ * @var string
+ */
+ private const NONE = 'none';
+
+ /**
+ * Open ability invocations, innermost last.
+ *
+ * @var array<int, array{name: string, channel: string, rest_namespace: string, external_client: string, start: float, executing: bool}>
+ */
+ private array $frames = array();
+
+ /**
+ * Tracks properties of events waiting to be sent.
+ *
+ * @var array<int, array<string, mixed>>
+ */
+ private array $events = array();
+
+ /**
+ * Whether this request is an MCP request: an MCP Adapter tool call happened, or the request has MCP HTTP headers.
+ *
+ * @var bool|null Null until checked.
+ */
+ private ?bool $is_mcp_request = null;
+
+ /**
+ * MCP client recorded with events, resolved when first needed.
+ *
+ * @var string|null
+ */
+ private ?string $external_client = null;
+
+ /**
+ * REST requests being dispatched, outermost first.
+ *
+ * @var array<int, WP_REST_Request<array<string, mixed>>>
+ */
+ private array $rest_requests = array();
+
+ /**
+ * Number of invocations opened when each REST request in $rest_requests started dispatching.
+ *
+ * @var int[]
+ */
+ private array $rest_frames_opened = array();
+
+ /**
+ * Number of events not recorded because the per-request limit was reached.
+ *
+ * @var int
+ */
+ private int $events_dropped = 0;
+
+ /**
+ * Number of invocations opened in this request.
+ *
+ * @var int
+ */
+ private int $frames_opened = 0;
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ add_action( 'init', array( $this, 'handle_init' ) );
+ }
+
+ /**
+ * Attach the tracking hooks when the store allows usage tracking, or on multisite.
+ *
+ * Consent is per site and a request can switch sites, so on multisite the hooks are always attached and consent is
+ * checked for each call instead.
+ *
+ * @internal
+ */
+ public function handle_init(): void {
+ if ( ! is_multisite() && ! self::is_tracking_allowed() ) {
+ return;
+ }
+
+ add_action( 'wp_ability_invoked', array( $this, 'handle_wp_ability_invoked' ) );
+ add_filter( 'wp_ability_normalize_input', array( $this, 'handle_wp_ability_normalize_input' ), PHP_INT_MAX, 2 );
+ add_filter( 'wp_pre_execute_ability', array( $this, 'handle_wp_pre_execute_ability' ), PHP_INT_MAX, 2 );
+ add_filter( 'wp_ability_validate_input', array( $this, 'handle_wp_ability_validate_input' ), PHP_INT_MAX, 3 );
+ add_filter( 'wp_ability_permission_result', array( $this, 'handle_wp_ability_permission_result' ), PHP_INT_MAX, 2 );
+ add_action( 'wp_before_execute_ability', array( $this, 'handle_wp_before_execute_ability' ) );
+ add_filter( 'wp_ability_execute_result', array( $this, 'handle_wp_ability_execute_result' ), PHP_INT_MAX, 2 );
+ add_filter( 'wp_ability_validate_output', array( $this, 'handle_wp_ability_validate_output' ), PHP_INT_MAX, 3 );
+ add_action( 'wp_after_execute_ability', array( $this, 'handle_wp_after_execute_ability' ) );
+ add_filter( 'mcp_adapter_pre_tool_call', array( $this, 'handle_mcp_adapter_pre_tool_call' ) );
+ add_filter( 'rest_request_before_callbacks', array( $this, 'handle_rest_request_before_callbacks' ), 10, 3 );
+ add_filter( 'rest_request_after_callbacks', array( $this, 'handle_rest_request_after_callbacks' ), 10, 3 );
+ // Last, so every other shutdown handler still finishes before the response does.
+ add_action( 'shutdown', array( $this, 'handle_shutdown' ), PHP_INT_MAX );
+ }
+
+ /**
+ * Handle the wp_ability_invoked action (WordPress 7.1+), which fires for every call.
+ *
+ * @internal
+ *
+ * @param string $ability_name The ability name.
+ */
+ public function handle_wp_ability_invoked( $ability_name ): void {
+ if ( self::is_tracked_ability( $ability_name ) ) {
+ $this->open_frame( $ability_name, false );
+ }
+ }
+
+ /**
+ * Handle the wp_ability_normalize_input filter (WordPress 7.1+), to record a rejected normalization.
+ *
+ * A missing input schema never reaches this filter. That failure stays unclosed and is recorded as unknown.
+ *
+ * @internal
+ *
+ * @param mixed $input The normalized input, or a WP_Error when a filter rejected it. Not recorded.
+ * @param string $ability_name The ability name.
+ * @return mixed The unchanged value.
+ */
+ public function handle_wp_ability_normalize_input( $input, $ability_name ) {
+ if ( is_wp_error( $input ) ) {
+ $this->close_frame( $ability_name, false, $input );
+ }
+ return $input;
+ }
+
+ /**
+ * Handle the wp_pre_execute_ability filter (WordPress 7.1+), to record short-circuited calls.
+ *
+ * @internal
+ *
+ * @param mixed $pre The pre-computed result, or the core sentinel to continue execution.
+ * @param string $ability_name The ability name.
+ * @return mixed The unchanged value.
+ */
+ public function handle_wp_pre_execute_ability( $pre, $ability_name ) {
+ // WP_Filter_Sentinel ships with WordPress 7.1, which the PHPStan WordPress stubs predate (see #69841).
+ if ( ! $pre instanceof \WP_Filter_Sentinel ) { // @phpstan-ignore class.notFound
+ $this->close_frame( $ability_name, false, $pre );
+ }
+ return $pre;
+ }
+
+ /**
+ * Handle the wp_ability_validate_input filter (WordPress 7.1+), to record invalid input.
+ *
+ * @internal
+ *
+ * @param mixed $is_valid True, or false or a WP_Error when the input is invalid.
+ * @param mixed $input The input. Not recorded.
+ * @param string $ability_name The ability name.
+ * @return mixed The unchanged value.
+ */
+ public function handle_wp_ability_validate_input( $is_valid, $input, $ability_name ) {
+ unset( $input );
+ $error = self::get_validation_error( $is_valid, 'ability_invalid_input' );
+ if ( $error ) {
+ $this->close_frame( $ability_name, false, $error );
+ }
+ return $is_valid;
+ }
+
+ /**
+ * Handle the wp_ability_permission_result filter (WordPress 7.1+), to record permission denials.
+ *
+ * @internal
+ *
+ * @param mixed $permission True when the call is allowed.
+ * @param string $ability_name The ability name.
+ * @return mixed The unchanged value.
+ */
+ public function handle_wp_ability_permission_result( $permission, $ability_name ) {
+ if ( true !== $permission ) {
+ // Matches the error WP_Ability::execute() returns for denied calls.
+ $error = new WP_Error( 'ability_invalid_permissions' );
+ // The MCP Adapter checks permission before executing and never runs a refused call, so no frame is open.
+ // Only a tool call counts: other permission checks in the same request aren't refused calls.
+ if ( self::is_tracked_ability( $ability_name ) && null === $this->find_frame( $ability_name, false ) && $this->is_json_rpc_tool_call() ) {
+ $this->add_event( $this->create_frame( $ability_name, false ), $error, false );
+ ++$this->frames_opened;
+ } else {
+ $this->close_frame( $ability_name, false, $error );
+ }
+ }
+ return $permission;
+ }
+
+ /**
+ * Handle the wp_before_execute_ability action.
+ *
+ * @internal
+ *
+ * @param string $ability_name The ability name.
+ */
+ public function handle_wp_before_execute_ability( $ability_name ): void {
+ if ( ! self::is_tracked_ability( $ability_name ) ) {
+ return;
+ }
+
+ $index = $this->find_frame( $ability_name, false );
+ if ( null !== $index ) {
+ // Opened by wp_ability_invoked; time only the execution so durations match on WordPress 7.0.
+ $frame = $this->frames[ $index ];
+ $frame['executing'] = true;
+ $frame['start'] = microtime( true );
+ $this->frames[ $index ] = $frame;
+ return;
+ }
+
+ $this->open_frame( $ability_name, true );
+ }
+
+ /**
+ * Handle the wp_ability_execute_result filter (WordPress 7.1+), to record execution errors.
+ *
+ * @internal
+ *
+ * @param mixed $result The execution result. Not recorded.
+ * @param string $ability_name The ability name.
+ * @return mixed The unchanged value.
+ */
+ public function handle_wp_ability_execute_result( $result, $ability_name ) {
+ if ( is_wp_error( $result ) ) {
+ $this->close_frame( $ability_name, true, $result );
+ }
+ return $result;
+ }
+
+ /**
+ * Handle the wp_ability_validate_output filter (WordPress 7.1+), to record invalid output.
+ *
+ * @internal
+ *
+ * @param mixed $is_valid True, or false or a WP_Error when the output is invalid.
+ * @param mixed $output The output. Not recorded.
+ * @param string $ability_name The ability name.
+ * @return mixed The unchanged value.
+ */
+ public function handle_wp_ability_validate_output( $is_valid, $output, $ability_name ) {
+ unset( $output );
+ $error = self::get_validation_error( $is_valid, 'ability_invalid_output' );
+ if ( $error ) {
+ $this->close_frame( $ability_name, true, $error );
+ }
+ return $is_valid;
+ }
+
+ /**
+ * Handle the wp_after_execute_ability action, which fires after a successful execution.
+ *
+ * @internal
+ *
+ * @param string $ability_name The ability name.
+ */
+ public function handle_wp_after_execute_ability( $ability_name ): void {
+ $this->close_frame( $ability_name, true, null );
+ }
+
+ /**
+ * Handle the mcp_adapter_pre_tool_call filter, to mark the request as an MCP request.
+ *
+ * Covers MCP Adapter transports without MCP HTTP headers, such as STDIO.
+ *
+ * @internal
+ *
+ * @param mixed $args The tool arguments.
+ * @return mixed The unchanged arguments.
+ */
+ public function handle_mcp_adapter_pre_tool_call( $args ) {
+ $this->is_mcp_request = true;
+ return $args;
+ }
+
+ /**
+ * Handle the rest_request_before_callbacks filter, to know which REST routes are being dispatched.
+ *
+ * @internal
+ *
+ * @param mixed $response The response, or null to continue dispatching.
+ * @param mixed $handler The route handler.
+ * @param mixed $request The request.
+ * @return mixed The unchanged response.
+ */
+ public function handle_rest_request_before_callbacks( $response, $handler, $request ) {
+ unset( $handler );
+ if ( $request instanceof WP_REST_Request ) {
+ $this->rest_requests[] = $request;
+ $this->rest_frames_opened[] = $this->frames_opened;
+ }
+ return $response;
+ }
+
+ /**
+ * Handle the rest_request_after_callbacks filter.
+ *
+ * Also records run requests that the Abilities API rejected before execution, for example on permission checks.
+ *
+ * @internal
+ *
+ * @param mixed $response The response.
+ * @param mixed $handler The route handler.
+ * @param mixed $request The request.
+ * @return mixed The unchanged response.
+ */
+ public function handle_rest_request_after_callbacks( $response, $handler, $request ) {
+ unset( $handler );
+ if ( ! $request instanceof WP_REST_Request ) {
+ return $response;
+ }
+
+ $frames_opened_before = (int) array_pop( $this->rest_frames_opened );
+ $ability_name = $request['name'];
+ if (
+ is_wp_error( $response )
+ // Abilities not exposed to REST look like unknown routes to callers; probing them isn't ability usage.
+ && 'rest_ability_not_found' !== $response->get_error_code()
+ && $this->frames_opened === $frames_opened_before
+ && 1 === preg_match( self::ABILITIES_RUN_ROUTE_REGEX, $request->get_route() )
+ && self::is_tracked_ability( $ability_name )
+ && wp_has_ability( $ability_name )
+ && self::is_tracking_allowed()
+ ) {
+ $this->add_event( $this->create_frame( $ability_name, false ), $response, false );
+ }
+
+ array_pop( $this->rest_requests );
+ return $response;
+ }
+
+ /**
+ * Handle the shutdown action: record calls that never finished, then send the queued events.
+ *
+ * @internal
+ */
+ public function handle_shutdown(): void {
+ while ( $this->frames ) {
+ $this->add_event( array_pop( $this->frames ), null, false );
+ }
+
+ if ( ! $this->events ) {
+ return;
+ }
+
+ try {
+ /**
+ * Filters whether the response is sent to the client before the ability events go to Tracks, on servers that
+ * support it (PHP-FPM, LiteSpeed). Return false to keep the client waiting for the send.
+ *
+ * @since 11.3.0
+ *
+ * @param bool $finish_response Whether to finish the response first. Default true.
+ */
+ if ( ! $this->is_long_running_process() && apply_filters( 'woocommerce_ability_tracking_finish_response', true ) ) {
+ $this->finish_response();
+ }
+ } catch ( \Throwable $e ) {
+ unset( $e );
+ }
+
+ $this->flush_events();
+ // This runs after WC_Tracks_Client's own send at priority 20, so the queued pixels are sent here.
+ try {
+ $this->send_queued_pixels();
+ } catch ( \Throwable $e ) {
+ unset( $e );
+ }
+ }
+
+ /**
+ * Queue one event in the batched Tracks client, which sends queued events on shutdown.
+ *
+ * @param array<string, mixed> $properties All Tracks properties of the event.
+ */
+ protected function send_event( array $properties ): void {
+ $user = wp_get_current_user();
+ // WC_Tracks::record_event() skips the test suite, whose users use the wptests_ table prefix.
+ if ( $user instanceof \WP_User && 'wptests_capabilities' === $user->cap_key ) {
+ return;
+ }
+ \WC_Tracks_Client::record_event_batched( $properties );
+ }
+
+ /**
+ * Send the response to the client and keep running, where the server supports it (PHP-FPM, LiteSpeed), as
+ * wp-cron.php and WC_Background_Emailer do. Elsewhere the client keeps waiting for the send, as before.
+ */
+ protected function finish_response(): void {
+ // Only one request can hold a PHP session; release it so the client's next request doesn't wait.
+ if ( session_id() ) {
+ session_write_close();
+ }
+ if ( function_exists( 'fastcgi_finish_request' ) ) {
+ fastcgi_finish_request();
+ } elseif ( function_exists( 'litespeed_finish_request' ) ) {
+ litespeed_finish_request();
+ }
+ }
+
+ /**
+ * Send the pixels queued in the batched Tracks client now, instead of on shutdown.
+ */
+ protected function send_queued_pixels(): void {
+ \WC_Tracks_Client::send_batched_pixels();
+ }
+
+ /**
+ * Whether this process can outlive many requests' worth of calls, such as an MCP STDIO server run with WP-CLI.
+ *
+ * @return bool
+ */
+ protected function is_long_running_process(): bool {
+ return defined( 'WP_CLI' ) && WP_CLI;
+ }
+
+ /**
+ * Hand the recorded events to the Tracks client.
+ */
+ private function flush_events(): void {
+ // Runs on shutdown, where an exception would also stop later shutdown callbacks.
+ try {
+ $last = array_key_last( $this->events );
+ if ( null !== $last && $this->events_dropped > 0 ) {
+ $this->events[ $last ]['events_dropped'] = $this->events_dropped;
+ $this->events_dropped = 0;
+ }
+
+ $events = $this->events;
+ $this->events = array();
+ } catch ( \Throwable $e ) {
+ unset( $e );
+ return;
+ }
+
+ foreach ( $events as $properties ) {
+ try {
+ $this->send_event( $properties );
+ } catch ( \Throwable $e ) {
+ unset( $e );
+ }
+ }
+ }
+
+ /**
+ * Start tracking an invocation.
+ *
+ * @param string $ability_name The ability name.
+ * @param bool $executing Whether execution already started (WordPress 7.0 path).
+ */
+ private function open_frame( string $ability_name, bool $executing ): void {
+ // Consent is per site, and the site can change mid-request with switch_to_blog().
+ if ( ! self::is_tracking_allowed() ) {
+ return;
+ }
+
+ // Only agent entry points. Direct PHP and cron calls are plugin internals and could add a pixel to every page view.
+ if ( ! in_array( $this->get_channel(), array( 'mcp', 'rest', 'cli' ), true ) ) {
+ return;
+ }
+
+ // Calls that fail without a closing hook keep their frame until shutdown. Recording the oldest one as an
+ // unknown failure keeps the stack bounded and lets later closing hooks still find their own frame.
+ if ( count( $this->frames ) >= self::MAX_EVENTS_PER_REQUEST ) {
+ $oldest = $this->frames[0];
+ array_shift( $this->frames );
+ $this->add_event( $oldest, null, false );
+ }
+
+ $this->frames[] = $this->create_frame( $ability_name, $executing );
+ ++$this->frames_opened;
+ }
+
+ /**
+ * Create the record of an invocation, with the context it started in.
+ *
+ * @param string $ability_name The ability name.
+ * @param bool $executing Whether execution already started.
+ * @return array{name: string, channel: string, rest_namespace: string, external_client: string, start: float, executing: bool}
+ */
+ private function create_frame( string $ability_name, bool $executing ): array {
+ $channel = $this->get_channel();
+ return array(
+ 'name' => $ability_name,
+ 'channel' => $channel,
+ 'rest_namespace' => $this->get_rest_namespace(),
+ // Resolved now: the request metadata it reads is gone once the REST request finishes.
+ 'external_client' => 'mcp' === $channel ? $this->get_external_client() : self::NONE,
+ 'start' => microtime( true ),
+ 'executing' => $executing,
+ );
+ }
+
+ /**
+ * Finish tracking the innermost open invocation of an ability.
+ *
+ * Invocations opened after it are calls that failed without a closing hook. On WordPress 7.0, a failed recursive
+ * call of the same ability can't be told apart from the outer call, so their outcomes can swap; counts stay right.
+ *
+ * @param mixed $ability_name The ability name.
+ * @param bool $executing Execution state the invocation must be in; hooks before and after execution share filters with other callers.
+ * @param mixed $result Null for success, a WP_Error for failure, or a short-circuited result.
+ */
+ private function close_frame( $ability_name, bool $executing, $result ): void {
+ if ( ! self::is_tracked_ability( $ability_name ) ) {
+ return;
+ }
+
+ $index = $this->find_frame( $ability_name, $executing );
+ if ( null === $index ) {
+ return;
+ }
+
+ $frame = $this->frames[ $index ];
+ foreach ( array_splice( $this->frames, $index ) as $offset => $closed_frame ) {
+ if ( $offset > 0 ) {
+ $this->add_event( $closed_frame, null, false );
+ }
+ }
+
+ // Only executed calls report a duration, so it always measures execution.
+ $this->add_event( $frame, is_wp_error( $result ) ? $result : true, $frame['executing'] );
+ }
+
+ /**
+ * Find the innermost open invocation of an ability in a given execution state.
+ *
+ * Matching the state keeps a recursive call that failed without a closing hook from hiding its outer call.
+ *
+ * @param string $ability_name The ability name.
+ * @param bool $executing Whether the invocation must have started executing.
+ * @return int|null Index in the frames stack, or null when none is open.
+ */
+ private function find_frame( string $ability_name, bool $executing ): ?int {
+ for ( $index = count( $this->frames ) - 1; $index >= 0; $index-- ) {
+ if ( $ability_name === $this->frames[ $index ]['name'] && $executing === $this->frames[ $index ]['executing'] ) {
+ return $index;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Turn a finished invocation into Tracks properties.
+ *
+ * Properties are built now, so the user, site and time are those of the call rather than of shutdown.
+ *
+ * @param array{name: string, channel: string, rest_namespace: string, external_client: string, start: float, executing: bool} $frame The invocation.
+ * @param true|WP_Error|null $outcome True for success, a WP_Error for a known failure, null for an unknown failure.
+ * @param bool $has_duration Whether the duration is known.
+ */
+ private function add_event( array $frame, $outcome, bool $has_duration ): void {
+ // Telemetry must never break the ability call: this runs third-party filters and Jetpack code inside core's hooks.
+ try {
+ if ( count( $this->events ) >= self::MAX_EVENTS_PER_REQUEST ) {
+ if ( ! $this->is_long_running_process() ) {
+ ++$this->events_dropped;
+ return;
+ }
+ $this->flush_events();
+ $this->send_queued_pixels();
+ }
+
+ // The call itself may have turned tracking off.
+ if ( ! self::is_tracking_allowed() ) {
+ return;
+ }
+
+ // Web requests from logged-out visitors aren't merchant or agent usage, and anyone could trigger them.
+ $auth = self::get_auth_method();
+ if ( 'none' === $auth && ! ( defined( 'WP_CLI' ) && WP_CLI ) && ! wp_doing_cron() ) {
+ return;
+ }
+
+ $error_code = self::NONE;
+ if ( $outcome instanceof WP_Error ) {
+ $error_code = self::get_error_code( $outcome );
+ } elseif ( null === $outcome ) {
+ $error_code = 'unknown';
+ }
+
+ $event = array(
+ // The `/` becomes `__`. Hyphens in the slug stay, so `products-query` and `products_query` stay different events.
+ 'ability_name' => implode( '__', explode( '/', strtolower( $frame['name'] ), 2 ) ),
+ 'outcome' => true === $outcome ? 'success' : 'error',
+ 'error_code' => $error_code,
+ 'channel' => $frame['channel'],
+ 'rest_namespace' => $frame['rest_namespace'],
+ 'auth' => $auth,
+ 'external_client' => $frame['external_client'],
+ );
+
+ if ( $has_duration ) {
+ $event['duration_ms'] = (int) round( ( microtime( true ) - $frame['start'] ) * 1000 );
+ }
+ // Many local installs (localhost, .local, .test, DDEV, Lando) still report production.
+ $event['is_test'] = ( 'production' !== wp_get_environment_type() || ( class_exists( Status::class ) && ( new Status() )->is_local_site() ) ) ? 'true' : 'false';
+ $event['is_a11n'] = self::is_a11n() ? 'true' : 'false';
+
+ $properties = \WC_Tracks::get_properties( \WC_Tracks::PREFIX . self::EVENT_NAME, $event );
+ // The request URL and referrer can carry ability input, e.g. in the query string of GET run requests.
+ unset( $properties['_dl'], $properties['_dr'] );
+ if ( null === $outcome ) {
+ // Unknown failures are recorded later than the call (WordPress 7.0 has no hook for them), so use the call's
+ // start time. Site and user are still read now; they differ only if the request switched them since.
+ $properties['_ts'] = number_format( round( $frame['start'] * 1000 ), 0, '', '' );
+ }
+
+ $this->events[] = $properties;
+ } catch ( \Throwable $e ) {
+ unset( $e );
+ }
+ }
+
+ /**
+ * Identify how the ability was invoked.
+ *
+ * @return string
+ */
+ private function get_channel(): string {
+ if ( $this->is_mcp_request() ) {
+ return 'mcp';
+ }
+ foreach ( $this->rest_requests as $request ) {
+ if ( 1 === preg_match( self::ABILITIES_RUN_ROUTE_REGEX, $request->get_route() ) ) {
+ return 'rest';
+ }
+ }
+ // Before WP-CLI: `wp cron event run` defines DOING_CRON, and that is scheduled work, not an agent.
+ if ( wp_doing_cron() ) {
+ return 'cron';
+ }
+ if ( defined( 'WP_CLI' ) && WP_CLI ) {
+ return 'cli';
+ }
+ return 'other';
+ }
+
+ /**
+ * Whether this is an MCP request.
+ *
+ * MCP Streamable HTTP clients send MCP-Protocol-Version on every request after initialization (2025-06-18 and
+ * later) and Mcp-Session-Id when the server assigned a session, whichever server implementation handles them.
+ *
+ * @return bool
+ */
+ private function is_mcp_request(): bool {
+ if ( null === $this->is_mcp_request ) {
+ $this->is_mcp_request = isset( $_SERVER['HTTP_MCP_PROTOCOL_VERSION'] ) || isset( $_SERVER['HTTP_MCP_SESSION_ID'] );
+ }
+ return $this->is_mcp_request || $this->is_json_rpc_tool_call();
+ }
+
+ /**
+ * Whether the outermost REST request is a JSON-RPC `tools/call`, as every MCP tool call over HTTP is, including from
+ * 2025-03-26 clients that send no MCP headers. Batches (2025-03-26) are checked message by message.
+ *
+ * @return bool
+ */
+ private function is_json_rpc_tool_call(): bool {
+ $request = $this->rest_requests[0] ?? null;
+ $body = $request ? $request->get_json_params() : null;
+ if ( ! is_array( $body ) ) {
+ return false;
+ }
+ foreach ( isset( $body['jsonrpc'] ) ? array( $body ) : $body as $message ) {
+ if ( is_array( $message ) && '2.0' === ( $message['jsonrpc'] ?? null ) && 'tools/call' === ( $message['method'] ?? null ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Get the namespace of the outermost REST route being dispatched, such as `wp_abilities` or `mcp`.
+ *
+ * Only the namespace is used: the rest of the route can hold IDs.
+ *
+ * @return string
+ */
+ private function get_rest_namespace(): string {
+ if ( ! $this->rest_requests ) {
+ return self::NONE;
+ }
+ $segments = explode( '/', ltrim( $this->rest_requests[0]->get_route(), '/' ), 2 );
+ return self::normalize_value( $segments[0] );
+ }
+
+ /**
+ * Get the error code to record for an error.
+ *
+ * Codes are normally fixed slugs, but any filter can return an error whose code was built from input, so codes
+ * that don't look like slugs are recorded as `other`.
+ *
+ * @param WP_Error $error The error.
+ * @return string
+ */
+ private static function get_error_code( WP_Error $error ): string {
+ $code = (string) $error->get_error_code();
+ return 1 === preg_match( '/^[A-Za-z0-9_-]{1,64}$/', $code ) ? self::normalize_value( $code ) : 'other';
+ }
+
+ /**
+ * Get the error a validation filter result stands for, matching how WP_Ability treats it.
+ *
+ * @param mixed $is_valid The filtered validation result.
+ * @param string $default_code Error code to use when the result is false.
+ * @return WP_Error|null The error, or null when the value is valid.
+ */
+ private static function get_validation_error( $is_valid, string $default_code ): ?WP_Error {
+ if ( false === $is_valid ) {
+ return new WP_Error( $default_code );
+ }
+ return is_wp_error( $is_valid ) && $is_valid->has_errors() ? $is_valid : null;
+ }
+
+ /**
+ * Identify how the caller authenticated, so agents can be told apart from people in wp-admin.
+ *
+ * @return string `application_password`, `jetpack`, `cookie`, `other` (another REST authentication method) or
+ * `none` (logged out, WP-CLI or cron).
+ */
+ private static function get_auth_method(): string {
+ if ( function_exists( 'rest_get_authenticated_app_password' ) && rest_get_authenticated_app_password() ) {
+ return 'application_password';
+ }
+ if ( self::is_jetpack_signed_request() ) {
+ return 'jetpack';
+ }
+ if ( ! is_user_logged_in() || ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() ) {
+ return 'none';
+ }
+ if ( wp_is_serving_rest_request() ) {
+ global $wp_rest_auth_cookie;
+ return true === $wp_rest_auth_cookie ? 'cookie' : 'other';
+ }
+ return 'cookie';
+ }
+
+ /**
+ * Whether Jetpack authenticated this request with a blog or user token signature.
+ *
+ * Jetpack is only asked when the request carries its signature arguments, so its REST authentication isn't
+ * initialized here on other requests.
+ *
+ * @return bool
+ */
+ private static function is_jetpack_signed_request(): bool {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only checks for the presence of Jetpack's signature arguments.
+ if ( ! isset( $_GET['token'], $_GET['signature'] ) ) {
+ return false;
+ }
+ $rest_authentication = 'Automattic\Jetpack\Connection\Rest_Authentication';
+ return is_callable( array( $rest_authentication, 'is_signed_with_blog_token' ) )
+ && is_callable( array( $rest_authentication, 'is_signed_with_user_token' ) )
+ && ( $rest_authentication::is_signed_with_blog_token() || $rest_authentication::is_signed_with_user_token() );
+ }
+
+ /**
+ * Whether an Automattician made the call: signed in on WordPress.com, or through the Automattic proxy.
+ *
+ * Only detectable on WordPress.com and Atomic; always false elsewhere.
+ *
+ * @return bool
+ */
+ private static function is_a11n(): bool {
+ if ( function_exists( 'is_automattician' ) && is_automattician() ) {
+ return true;
+ }
+ if ( defined( 'AT_PROXIED_REQUEST' ) && AT_PROXIED_REQUEST ) {
+ return true;
+ }
+ if ( function_exists( 'wpcom_is_proxied_request' ) ) {
+ return (bool) wpcom_is_proxied_request();
+ }
+ if ( isset( $_SERVER['A8C_PROXIED_REQUEST'] ) && is_string( $_SERVER['A8C_PROXIED_REQUEST'] ) ) {
+ return '1' === sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) );
+ }
+ return defined( 'A8C_PROXIED_REQUEST' ) && A8C_PROXIED_REQUEST;
+ }
+
+ /**
+ * Whether the current site allows usage tracking.
+ *
+ * Unlike WC_Site_Tracking::is_tracking_enabled() alone, this requires the stored opt-in: that method also treats a
+ * posted `wc_tracker_checkbox=yes` (the onboarding opt-in) as consent, and any visitor can post to the REST API.
+ *
+ * @return bool
+ */
+ private static function is_tracking_allowed(): bool {
+ try {
+ return 'yes' === get_option( 'woocommerce_allow_tracking' ) && \WC_Site_Tracking::is_tracking_enabled();
+ } catch ( \Throwable $e ) {
+ // The consent filters are third-party code; a failure there must not break the ability call.
+ return false;
+ }
+ }
+
+ /**
+ * Whether an ability is in the tracked namespace.
+ *
+ * @param mixed $ability_name The ability name.
+ * @return bool
+ */
+ private static function is_tracked_ability( $ability_name ): bool {
+ return is_string( $ability_name ) && 0 === strpos( $ability_name, self::ABILITY_PREFIX );
+ }
+
+ /**
+ * Get the MCP client, as one of the known clients, `other` or `none`.
+ *
+ * The client supplies its own name, so only known clients are recorded by name.
+ *
+ * @return string
+ */
+ private function get_external_client(): string {
+ try {
+ return $this->resolve_external_client();
+ } catch ( \Throwable $e ) {
+ // The session lookup is MCP Adapter code; a failure there must not break the ability call.
+ return self::NONE;
+ }
+ }
+
+ /**
+ * Resolve and cache the MCP client.
+ *
+ * @return string
+ */
+ private function resolve_external_client(): string {
+ if ( null === $this->external_client ) {
+ $name = $this->get_mcp_client_name_from_request() ?? self::get_mcp_client_name_from_session();
+ if ( null === $name ) {
+ $this->external_client = self::NONE;
+ } else {
+ $this->external_client = 'other';
+ $tokens = '_' . self::normalize_value( $name ) . '_';
+ foreach ( self::KNOWN_MCP_CLIENTS as $needle => $client ) {
+ if ( false !== strpos( $tokens, '_' . $needle . '_' ) ) {
+ $this->external_client = $client;
+ break;
+ }
+ }
+ }
+ }
+ return $this->external_client;
+ }
+
+ /**
+ * Get the MCP client name from the request metadata, which MCP 2026-07-28 sends with every request.
+ *
+ * @return string|null
+ */
+ private function get_mcp_client_name_from_request(): ?string {
+ $request = $this->rest_requests[0] ?? null;
+ $message = $request ? $request->get_json_params() : null;
+ $name = is_array( $message ) ? ( $message['params']['_meta']['io.modelcontextprotocol/clientInfo']['name'] ?? null ) : null;
+ return is_string( $name ) ? $name : null;
+ }
+
+ /**
+ * Get the MCP client name from the MCP Adapter session (MCP 2025-11-25 and earlier).
+ *
+ * @return string|null
+ */
+ private static function get_mcp_client_name_from_session(): ?string {
+ $session_manager = array( 'WP\MCP\Transport\Infrastructure\SessionManager', 'get_session' );
+ $session_id = isset( $_SERVER['HTTP_MCP_SESSION_ID'] ) && is_string( $_SERVER['HTTP_MCP_SESSION_ID'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_MCP_SESSION_ID'] ) ) : '';
+
+ if ( '' === $session_id || ! get_current_user_id() || ! is_callable( $session_manager ) ) {
+ return null;
+ }
+
+ $session = call_user_func( $session_manager, get_current_user_id(), $session_id );
+ $name = is_array( $session ) ? ( $session['client_params']['clientInfo']['name'] ?? null ) : null;
+
+ return is_string( $name ) ? $name : null;
+ }
+
+ /**
+ * Convert a value to lowercase words separated by underscores, as Tracks property values expect.
+ *
+ * @param string $value The value.
+ * @param int|null $max_length Maximum length, or null for no limit.
+ * @return string The normalized value, or 'none' when nothing is left.
+ */
+ private static function normalize_value( string $value, ?int $max_length = 64 ): string {
+ $value = trim( (string) preg_replace( '/[^a-z0-9]+/', '_', strtolower( $value ) ), '_' );
+ if ( '' === $value ) {
+ return self::NONE;
+ }
+ return null === $max_length ? $value : (string) substr( $value, 0, $max_length );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
new file mode 100644
index 00000000000..3cd1765e9b3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
@@ -0,0 +1,1249 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesTracking;
+use WC_Unit_Test_Case;
+use WP_Error;
+use WP_REST_Request;
+
+/**
+ * Tests for the AbilitiesTracking class.
+ */
+class AbilitiesTrackingTest extends WC_Unit_Test_Case {
+
+ private const ABILITY_ID = 'woocommerce/tracking-test';
+
+ private const INNER_ABILITY_ID = 'woocommerce/tracking-test-inner';
+
+ private const UNTRACKED_ABILITY_ID = 'tracking-test/other';
+
+ private const LONG_ABILITY_ID = 'woocommerce/tracking-test-with-a-long-name-that-goes-past-sixty-four-characters';
+
+ private const NO_SCHEMA_ABILITY_ID = 'woocommerce/tracking-test-no-schema';
+
+ private const SERVER_KEYS = array( 'REQUEST_SCHEME', 'HTTP_HOST', 'REQUEST_URI', 'HTTP_REFERER', 'HTTP_MCP_PROTOCOL_VERSION', 'HTTP_MCP_SESSION_ID', 'A8C_PROXIED_REQUEST' );
+
+ /**
+ * Hooks that exist only on WordPress 7.1. Removed from this instance to exercise the 7.0 path.
+ *
+ * @var string[]
+ */
+ private const WORDPRESS_7_1_HOOKS = array(
+ 'wp_ability_invoked',
+ 'wp_pre_execute_ability',
+ 'wp_ability_normalize_input',
+ 'wp_ability_validate_input',
+ 'wp_ability_permission_result',
+ 'wp_ability_execute_result',
+ 'wp_ability_validate_output',
+ );
+
+ /**
+ * The System Under Test, with events captured instead of sent.
+ *
+ * @var AbilitiesTracking&object{recorded: array}
+ */
+ private $sut;
+
+ /**
+ * What the test ability's execute callback returns, or a callable that produces it.
+ *
+ * @var mixed
+ */
+ private $execute_result = array( 'ok' => true );
+
+ /**
+ * Value the test ability's permission callback returns.
+ *
+ * @var mixed
+ */
+ private $permission = true;
+
+ /**
+ * What the inner test ability's execute callback returns.
+ *
+ * @var mixed
+ */
+ private $inner_result = array( 'ok' => true );
+
+ /**
+ * Administrator user ID.
+ *
+ * @var int
+ */
+ private static int $administrator_id;
+
+ /**
+ * Original action counts, restored on tear down.
+ *
+ * @var array<string, int|null>
+ */
+ private array $original_action_counts = array();
+
+ /**
+ * Original $_SERVER values, restored on tear down.
+ *
+ * @var array<string, mixed>
+ */
+ private array $original_server = array();
+
+ /**
+ * Create shared fixtures.
+ *
+ * @param \WP_UnitTest_Factory $factory Factory.
+ */
+ public static function wpSetUpBeforeClass( $factory ): void {
+ self::$administrator_id = $factory->user->create( array( 'role' => 'administrator' ) );
+ }
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ global $wp_actions;
+
+ parent::setUp();
+
+ if ( ! function_exists( 'wp_register_ability' ) ) {
+ $this->markTestSkipped( 'The Abilities API is not available.' );
+ }
+
+ foreach ( array( 'init', 'wp_abilities_api_init', 'wp_abilities_api_categories_init', 'rest_api_init' ) as $action ) {
+ $this->original_action_counts[ $action ] = $wp_actions[ $action ] ?? null;
+ }
+ // The Abilities API registry requires init to have fired.
+ $wp_actions['init'] = max( 1, (int) ( $wp_actions['init'] ?? 0 ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ foreach ( self::SERVER_KEYS as $key ) {
+ $this->original_server[ $key ] = $_SERVER[ $key ] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
+ }
+
+ wp_set_current_user( self::$administrator_id );
+ $this->register_test_abilities();
+
+ update_option( 'woocommerce_allow_tracking', 'yes' );
+
+ $this->sut = $this->create_sut();
+ $this->sut->handle_init();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ global $wp_actions;
+
+ try {
+ if ( isset( $this->sut ) ) {
+ $this->remove_instance_callbacks( $this->sut );
+ }
+
+ // The abilities registry is a singleton that outlives the test.
+ foreach ( array( self::ABILITY_ID, self::INNER_ABILITY_ID, self::UNTRACKED_ABILITY_ID, self::LONG_ABILITY_ID, self::NO_SCHEMA_ABILITY_ID ) as $ability_id ) {
+ if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_id ) ) {
+ wp_unregister_ability( $ability_id );
+ }
+ }
+
+ foreach ( $this->original_action_counts as $action => $original_count ) {
+ if ( null === $original_count ) {
+ unset( $wp_actions[ $action ] );
+ } else {
+ $wp_actions[ $action ] = $original_count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+ }
+
+ foreach ( $this->original_server as $key => $value ) {
+ if ( null === $value ) {
+ unset( $_SERVER[ $key ] );
+ } else {
+ $_SERVER[ $key ] = $value;
+ }
+ }
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox Should record a successful MCP call with normalized properties.
+ */
+ public function test_records_successful_mcp_call(): void {
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 1, $this->sut->recorded, 'One event should be recorded per call' );
+ $event = $this->sut->recorded[0];
+ $this->assertSame( 'wcadmin_ability_called', $event['_en'] );
+ $this->assertSame( 'woocommerce__tracking-test', $event['ability_name'] );
+ $this->assertSame( 'success', $event['outcome'] );
+ $this->assertSame( 'none', $event['error_code'] );
+ $this->assertSame( 'mcp', $event['channel'] );
+ $this->assertSame( 'none', $event['external_client'], 'Without an MCP session the client is unknown' );
+ $this->assertIsInt( $event['duration_ms'] );
+ $this->assertArrayNotHasKey( 'error', $event, 'A property named error makes Tracks drop the event' );
+ }
+
+ /**
+ * @testdox Should not send ability input, including input carried in the request URL or referrer.
+ */
+ public function test_does_not_send_ability_input(): void {
+ $_SERVER['REQUEST_SCHEME'] = 'https';
+ $_SERVER['HTTP_HOST'] = 'example.com';
+ $_SERVER['REQUEST_URI'] = '/wp-json/wp-abilities/v1/abilities/woocommerce/tracking-test/run?input[search]=customer@example.com';
+ $_SERVER['HTTP_REFERER'] = 'https://example.com/?search=customer@example.com';
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::ABILITY_ID )->execute( array( 'search' => 'customer@example.com' ) );
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 1, $this->sut->recorded );
+ $this->assertArrayNotHasKey( '_dl', $this->sut->recorded[0] );
+ $this->assertArrayNotHasKey( '_dr', $this->sut->recorded[0] );
+ $this->assertStringNotContainsString( 'customer@example.com', (string) wp_json_encode( $this->sut->recorded[0] ) );
+ }
+
+ /**
+ * @testdox Should record calls made through the core Abilities API run endpoint as the rest channel.
+ */
+ public function test_records_rest_run_endpoint_call(): void {
+ $response = rest_do_request( new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/' . self::ABILITY_ID . '/run' ) );
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( 200, $response->get_status(), 'The ability should run through REST' );
+ $this->assertCount( 1, $this->sut->recorded, 'The direct call after the REST request is not an agent call' );
+ $this->assertSame( 'rest', $this->sut->recorded[0]['channel'] );
+ $this->assertSame( 'wp_abilities', $this->sut->recorded[0]['rest_namespace'] );
+ $this->assertSame( 'success', $this->sut->recorded[0]['outcome'] );
+ }
+
+ /**
+ * @testdox Should record run requests rejected by the Abilities REST API before execution.
+ */
+ public function test_records_rest_rejection_before_execution(): void {
+ $this->permission = false;
+
+ $response = rest_do_request( new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/' . self::ABILITY_ID . '/run' ) );
+ $this->sut->handle_shutdown();
+
+ $this->assertGreaterThanOrEqual( 400, $response->get_status() );
+ $this->assertCount( 1, $this->sut->recorded );
+ $this->assertSame( 'rest', $this->sut->recorded[0]['channel'] );
+ $this->assertSame( 'error', $this->sut->recorded[0]['outcome'] );
+ $this->assertSame( 'rest_ability_cannot_execute', $this->sut->recorded[0]['error_code'] );
+ $this->assertArrayNotHasKey( 'duration_ms', $this->sut->recorded[0] );
+ }
+
+ /**
+ * @testdox Should record a REST run request that fails during execution only once.
+ */
+ public function test_records_rest_execution_failure_once(): void {
+ $this->execute_result = new WP_Error( 'tracking_test_failure' );
+
+ rest_do_request( new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/' . self::ABILITY_ID . '/run' ) );
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 1, $this->sut->recorded );
+ $this->assertSame( 'error', $this->sut->recorded[0]['outcome'] );
+ }
+
+ /**
+ * @testdox Should not record calls inside other REST routes.
+ */
+ public function test_ignores_calls_inside_other_rest_routes(): void {
+ $this->register_route_that_runs_ability( 'tracking-test/v1', '/orders/(?P<id>\d+)', 'GET' );
+
+ rest_do_request( new WP_REST_Request( 'GET', '/tracking-test/v1/orders/123' ) );
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( array(), $this->sut->recorded );
+ }
+
+ /**
+ * @testdox Should record known MCP clients from request metadata, and other clients as other.
+ * @testWith ["Claude Code", "claude_code"]
+ * ["claude-ai", "claude"]
+ * ["cursor-vscode", "cursor"]
+ * ["Visual Studio Code", "vscode"]
+ * ["openai-mcp", "chatgpt"]
+ * ["Jane's laptop agent", "other"]
+ *
+ * @param string $client_name Client name sent by the MCP client.
+ * @param string $expected Recorded external_client.
+ */
+ public function test_records_known_mcp_clients_from_request_metadata( string $client_name, string $expected ): void {
+ $_SERVER['HTTP_MCP_PROTOCOL_VERSION'] = '2026-07-28';
+ $this->register_route_that_runs_ability( 'tracking-mcp/v1', '/mcp', 'POST' );
+ $request = new WP_REST_Request( 'POST', '/tracking-mcp/v1/mcp' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body(
+ (string) wp_json_encode(
+ array(
+ 'jsonrpc' => '2.0',
+ 'id' => 1,
+ 'method' => 'tools/call',
+ 'params' => array(
+ 'name' => 'products-query',
+ '_meta' => array( 'io.modelcontextprotocol/clientInfo' => array( 'name' => $client_name ) ),
+ ),
+ )
+ )
+ );
+
+ rest_do_request( $request );
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( 'mcp', $this->sut->recorded[0]['channel'] );
+ $this->assertSame( $expected, $this->sut->recorded[0]['external_client'] );
+ }
+
+ /**
+ * @testdox Should read the MCP client from the MCP Adapter session when the request has no client metadata.
+ */
+ public function test_records_mcp_client_from_adapter_session(): void {
+ if ( ! class_exists( 'WP\MCP\Transport\Infrastructure\SessionManager' ) ) {
+ class_alias( FakeMcpSessionManager::class, 'WP\MCP\Transport\Infrastructure\SessionManager' );
+ }
+ if ( ! is_a( 'WP\MCP\Transport\Infrastructure\SessionManager', FakeMcpSessionManager::class, true ) ) {
+ $this->markTestSkipped( 'The MCP Adapter plugin is loaded.' );
+ }
+ $_SERVER['HTTP_MCP_SESSION_ID'] = 'session-1';
+ FakeMcpSessionManager::$sessions[ self::$administrator_id ]['session-1'] = array( 'client_params' => array( 'clientInfo' => array( 'name' => 'claude-code' ) ) );
+
+ try {
+ wp_get_ability( self::ABILITY_ID )->execute();
+ } finally {
+ FakeMcpSessionManager::$sessions = array();
+ }
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( 'claude_code', $this->sut->recorded[0]['external_client'] );
+ }
+
+ /**
+ * @testdox Should detect MCP requests from standard MCP HTTP headers, whatever server handles them.
+ * @testWith ["HTTP_MCP_PROTOCOL_VERSION", "2025-11-25"]
+ * ["HTTP_MCP_SESSION_ID", "1868a90c"]
+ *
+ * @param string $header Server key of the header.
+ * @param string $value Header value.
+ */
+ public function test_detects_mcp_requests_from_headers( string $header, string $value ): void {
+ $_SERVER[ $header ] = $value;
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( 'mcp', $this->sut->recorded[0]['channel'] );
+ }
+
+ /**
+ * @testdox Should detect MCP tool calls from a JSON-RPC body when the client sends no MCP headers.
+ * @testWith [false]
+ * [true]
+ *
+ * @param bool $batch Whether the tool call is sent in a JSON-RPC batch.
+ */
+ public function test_detects_mcp_tool_calls_from_json_rpc_body( bool $batch ): void {
+ $this->register_route_that_runs_ability( 'tracking-test/v1', '/mcp', 'POST' );
+ $message = array(
+ 'jsonrpc' => '2.0',
+ 'id' => 1,
+ 'method' => 'tools/call',
+ 'params' => array( 'name' => 'tracking-test' ),
+ );
+ $request = new WP_REST_Request( 'POST', '/tracking-test/v1/mcp' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( (string) wp_json_encode( $batch ? array( $message ) : $message ) );
+
+ rest_do_request( $request );
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 1, $this->sut->recorded );
+ $this->assertSame( 'mcp', $this->sut->recorded[0]['channel'] );
+ }
+
+ /**
+ * @testdox Should record MCP tool calls the MCP Adapter refuses on its permission check before execution, and no other permission checks.
+ * @testWith ["tools/call", 1]
+ * ["tools/list", 0]
+ *
+ * @param string $method JSON-RPC method of the MCP request.
+ * @param int $expected Expected number of events.
+ */
+ public function test_records_mcp_calls_refused_before_execution( string $method, int $expected ): void {
+ $this->skip_unless_wordpress_exposes_all_outcomes();
+ // An MCP request by its headers, so only the JSON-RPC method tells a tool call from a listing.
+ $_SERVER['HTTP_MCP_PROTOCOL_VERSION'] = '2025-11-25';
+ $this->permission = false;
+ $request = new WP_REST_Request( 'POST', '/mcp/mcp-adapter-default-server' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body(
+ (string) wp_json_encode(
+ array(
+ 'jsonrpc' => '2.0',
+ 'id' => 1,
+ 'method' => $method,
+ )
+ )
+ );
+ $this->sut->handle_rest_request_before_callbacks( null, null, $request );
+
+ wp_get_ability( self::ABILITY_ID )->check_permissions();
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( $expected, $this->sut->recorded );
+ if ( 0 === $expected ) {
+ return;
+ }
+ $this->assertSame( 'error', $this->sut->recorded[0]['outcome'] );
+ $this->assertSame( 'ability_invalid_permissions', $this->sut->recorded[0]['error_code'] );
+ $this->assertSame( 'mcp', $this->sut->recorded[0]['channel'] );
+ }
+
+ /**
+ * @testdox Should not report Jetpack authentication for signature arguments Jetpack didn't verify.
+ */
+ public function test_does_not_trust_unverified_jetpack_signature_arguments(): void {
+ $this->start_mcp_tool_call();
+ $_GET['token'] = 'abc:1:2';
+ $_GET['signature'] = 'forged';
+
+ try {
+ wp_get_ability( self::ABILITY_ID )->execute();
+ } finally {
+ unset( $_GET['token'], $_GET['signature'] );
+ }
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( 'cookie', $this->sut->recorded[0]['auth'] );
+ }
+
+ /**
+ * @testdox Should record how the caller authenticated.
+ */
+ public function test_records_auth_method(): void {
+ global $wp_rest_application_password_uuid;
+
+ $this->start_mcp_tool_call();
+ $ability = wp_get_ability( self::ABILITY_ID );
+ $ability->execute();
+ $wp_rest_application_password_uuid = 'f5bd2b1a-0000-4000-8000-000000000000'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ try {
+ $ability->execute();
+ } finally {
+ $wp_rest_application_password_uuid = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( array( 'cookie', 'application_password' ), array_column( $this->sut->recorded, 'auth' ) );
+ }
+
+ /**
+ * @testdox Should record whether the call came through the Automattic proxy.
+ */
+ public function test_records_is_a11n(): void {
+ $this->start_mcp_tool_call();
+ $ability = wp_get_ability( self::ABILITY_ID );
+ $ability->execute();
+ $_SERVER['A8C_PROXIED_REQUEST'] = '1';
+ $ability->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( array( 'false', 'true' ), array_column( $this->sut->recorded, 'is_a11n' ) );
+ }
+
+ /**
+ * @testdox Should record WordPress 7.1+ failure outcomes with their error codes.
+ * @dataProvider provide_wordpress_7_1_failures
+ *
+ * @param string $failure Failure to cause.
+ * @param string $expected_code Expected error code.
+ * @param bool $executed Whether the call reached execution, and so reports a duration.
+ */
+ public function test_records_wordpress_7_1_failures( string $failure, string $expected_code, bool $executed ): void {
+ $this->skip_unless_wordpress_exposes_all_outcomes();
+ switch ( $failure ) {
+ case 'execute_error':
+ $this->execute_result = new WP_Error( 'tracking_test_failure', 'Failed for customer@example.com' );
+ break;
+ case 'permission_denied':
+ $this->permission = false;
+ break;
+ case 'input_rejected_with_false':
+ add_filter( 'wp_ability_validate_input', '__return_false' );
+ break;
+ case 'output_rejected_with_error':
+ add_filter( 'wp_ability_validate_output', fn() => new WP_Error( 'tracking_test_bad_output' ) );
+ break;
+ case 'short_circuit_error':
+ add_filter( 'wp_pre_execute_ability', fn() => new WP_Error( 'tracking_test_rate_limited' ) );
+ break;
+ }
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 1, $this->sut->recorded );
+ $this->assertSame( 'error', $this->sut->recorded[0]['outcome'] );
+ $this->assertSame( $expected_code, $this->sut->recorded[0]['error_code'] );
+ $this->assertSame( $executed, array_key_exists( 'duration_ms', $this->sut->recorded[0] ), 'Only executed calls report a duration' );
+ }
+
+ /**
+ * Failures WordPress 7.1+ reports through hooks, with the expected error code.
+ *
+ * @return array<string, array{string, string, bool}>
+ */
+ public function provide_wordpress_7_1_failures(): array {
+ return array(
+ 'execute callback error' => array( 'execute_error', 'tracking_test_failure', true ),
+ 'permission denied' => array( 'permission_denied', 'ability_invalid_permissions', false ),
+ 'input filter false' => array( 'input_rejected_with_false', 'ability_invalid_input', false ),
+ 'output filter error' => array( 'output_rejected_with_error', 'tracking_test_bad_output', true ),
+ 'short-circuit error' => array( 'short_circuit_error', 'tracking_test_rate_limited', false ),
+ );
+ }
+
+ /**
+ * @testdox Should record only slug-like error codes.
+ * @testWith ["tracking_test_failure", "tracking_test_failure"]
+ * ["Tracking-Test-Failure", "tracking_test_failure"]
+ * ["customer@example.com", "other"]
+ * ["order 1234 for Jane", "other"]
+ *
+ * @param string $code Error code returned by the ability.
+ * @param string $expected Recorded error code.
+ */
+ public function test_records_only_slug_like_error_codes( string $code, string $expected ): void {
+ $this->skip_unless_wordpress_exposes_all_outcomes();
+ $this->execute_result = new WP_Error( $code );
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( $expected, $this->sut->recorded[0]['error_code'] );
+ }
+
+ /**
+ * @testdox Should record an input-normalization error with its error code.
+ */
+ public function test_records_normalize_input_error(): void {
+ $this->skip_unless_wordpress_exposes_all_outcomes();
+ add_filter( 'wp_ability_normalize_input', fn() => new WP_Error( 'tracking_test_normalize' ) );
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 1, $this->sut->recorded );
+ $this->assertSame( 'tracking_test_normalize', $this->sut->recorded[0]['error_code'] );
+ $this->assertArrayNotHasKey( 'duration_ms', $this->sut->recorded[0] );
+ }
+
+ /**
+ * @testdox Should record a missing input schema as unknown, because that failure has no closing hook.
+ */
+ public function test_records_missing_input_schema_as_unknown(): void {
+ $this->skip_unless_wordpress_exposes_all_outcomes();
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::NO_SCHEMA_ABILITY_ID )->execute( array( 'raw' => true ) );
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 1, $this->sut->recorded );
+ $this->assertSame( 'unknown', $this->sut->recorded[0]['error_code'] );
+ $this->assertArrayNotHasKey( 'duration_ms', $this->sut->recorded[0] );
+ }
+
+ /**
+ * @testdox Should record a short-circuited call as a success.
+ */
+ public function test_records_short_circuited_call(): void {
+ $this->skip_unless_wordpress_exposes_all_outcomes();
+ add_filter( 'wp_pre_execute_ability', fn() => array( 'cached' => true ) );
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 1, $this->sut->recorded );
+ $this->assertSame( 'success', $this->sut->recorded[0]['outcome'] );
+ }
+
+ /**
+ * @testdox Should record nested WooCommerce ability calls once each.
+ */
+ public function test_records_nested_calls_once_each(): void {
+ $this->execute_result = fn() => wp_get_ability( self::INNER_ABILITY_ID )->execute();
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $outcomes = array_column( $this->sut->recorded, 'outcome', 'ability_name' );
+ ksort( $outcomes );
+ $this->assertCount( 2, $this->sut->recorded );
+ $this->assertSame(
+ array(
+ 'woocommerce__tracking-test' => 'success',
+ 'woocommerce__tracking-test-inner' => 'success',
+ ),
+ $outcomes
+ );
+ }
+
+ /**
+ * @testdox Should record both calls when a failed call is followed by a successful one.
+ */
+ public function test_records_failed_call_followed_by_successful_call(): void {
+ $this->start_mcp_tool_call();
+ $ability = wp_get_ability( self::ABILITY_ID );
+
+ $this->execute_result = new WP_Error( 'tracking_test_failure' );
+ $ability->execute();
+ $this->execute_result = array( 'ok' => true );
+ $ability->execute();
+ $this->sut->handle_shutdown();
+
+ $outcomes = array_column( $this->sut->recorded, 'outcome' );
+ sort( $outcomes );
+ $this->assertSame( array( 'error', 'success' ), $outcomes );
+ }
+
+ /**
+ * @testdox Should fall back to the execution hooks available in WordPress 7.0.
+ */
+ public function test_falls_back_to_wordpress_7_0_hooks(): void {
+ $this->remove_instance_callbacks( $this->sut, self::WORDPRESS_7_1_HOOKS );
+ $this->start_mcp_tool_call();
+ $ability = wp_get_ability( self::ABILITY_ID );
+
+ $ability->execute();
+ $this->execute_result = new WP_Error( 'tracking_test_failure' );
+ $ability->execute();
+ $this->sut->handle_shutdown();
+
+ $events = array_column( $this->sut->recorded, null, 'outcome' );
+ $this->assertCount( 2, $this->sut->recorded );
+ $this->assertIsInt( $events['success']['duration_ms'] );
+ $this->assertSame( 'unknown', $events['error']['error_code'], 'Failed executions fire no hook in WordPress 7.0' );
+ $this->assertArrayNotHasKey( 'duration_ms', $events['error'] );
+ }
+
+ /**
+ * @testdox Should not record calls made during cron.
+ */
+ public function test_ignores_cron_calls(): void {
+ add_filter( 'wp_doing_cron', '__return_true' );
+ wp_set_current_user( 0 );
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( array(), $this->sut->recorded );
+ }
+
+ /**
+ * @testdox Should finish the response before sending events, unless there are none, it runs under WP-CLI, or a filter says not to.
+ */
+ public function test_finishes_the_response_before_sending_events(): void {
+ $this->start_mcp_tool_call();
+ $this->sut->handle_shutdown();
+ $this->assertSame( array(), $this->sut->finished_after, 'There was nothing to send' );
+ $this->assertSame( array(), $this->sut->pixels_sent_after, 'Nothing to send' );
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+ $this->assertSame( array( 0 ), $this->sut->finished_after, 'Once, before the event was sent' );
+ $this->assertSame( array( 1 ), $this->sut->pixels_sent_after, 'The pixels go out after the response is finished' );
+
+ add_filter( 'woocommerce_ability_tracking_finish_response', '__return_false' );
+ try {
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+ } finally {
+ remove_filter( 'woocommerce_ability_tracking_finish_response', '__return_false' );
+ }
+ $this->assertSame( array( 0 ), $this->sut->finished_after, 'The filter keeps the response open' );
+ $this->assertSame( array( 1, 1 ), $this->sut->pixels_sent_after, 'The events are still sent' );
+
+ $this->sut->long_running = true;
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+ $this->assertSame( array( 0 ), $this->sut->finished_after, 'No client waits under WP-CLI' );
+ }
+
+ /**
+ * @testdox Should not record calls or REST rejections from logged-out visitors.
+ */
+ public function test_ignores_logged_out_visitors(): void {
+ wp_set_current_user( 0 );
+
+ $this->start_mcp_tool_call();
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->permission = false;
+ rest_do_request( new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/' . self::ABILITY_ID . '/run' ) );
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( array(), $this->sut->recorded );
+ }
+
+ /**
+ * @testdox Should record a failed inner call as unknown when its outer call succeeds on WordPress 7.0.
+ */
+ public function test_records_failed_inner_call_on_wordpress_7_0(): void {
+ $this->remove_instance_callbacks( $this->sut, self::WORDPRESS_7_1_HOOKS );
+ $this->inner_result = new WP_Error( 'tracking_test_inner_failure' );
+ $this->execute_result = function () {
+ wp_get_ability( self::INNER_ABILITY_ID )->execute();
+ return array( 'ok' => true );
+ };
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $events = array_column( $this->sut->recorded, null, 'ability_name' );
+ $this->assertCount( 2, $this->sut->recorded );
+ $this->assertSame( 'success', $events['woocommerce__tracking-test']['outcome'] );
+ $this->assertSame( 'unknown', $events['woocommerce__tracking-test-inner']['error_code'] );
+ }
+
+ /**
+ * @testdox Should keep the client and time of an MCP call whose failure is only recorded at shutdown.
+ */
+ public function test_keeps_context_of_failures_recorded_at_shutdown(): void {
+ $this->remove_instance_callbacks( $this->sut, self::WORDPRESS_7_1_HOOKS );
+ $_SERVER['HTTP_MCP_PROTOCOL_VERSION'] = '2026-07-28';
+ $this->execute_result = new WP_Error( 'tracking_test_failure' );
+ $this->register_route_that_runs_ability( 'tracking-mcp/v1', '/mcp', 'POST' );
+ $request = new WP_REST_Request( 'POST', '/tracking-mcp/v1/mcp' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( (string) wp_json_encode( array( 'params' => array( '_meta' => array( 'io.modelcontextprotocol/clientInfo' => array( 'name' => 'claude-code' ) ) ) ) ) );
+
+ $called_at = microtime( true ) * 1000;
+ rest_do_request( $request );
+ $returned_at = microtime( true ) * 1000;
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( 'unknown', $this->sut->recorded[0]['error_code'] );
+ $this->assertSame( 'claude_code', $this->sut->recorded[0]['external_client'], 'The client must be read while the request is active' );
+ $this->assertGreaterThanOrEqual( floor( $called_at ), (float) $this->sut->recorded[0]['_ts'] );
+ $this->assertLessThanOrEqual( ceil( $returned_at ), (float) $this->sut->recorded[0]['_ts'], 'The event should carry the time of the call, not of shutdown' );
+ }
+
+ /**
+ * @testdox Should keep the outer call's result when a recursive call fails during input normalization.
+ */
+ public function test_keeps_outer_result_when_recursive_call_fails_without_closing_hook(): void {
+ $this->skip_unless_wordpress_exposes_all_outcomes();
+ $calls = 0;
+ add_filter(
+ 'wp_ability_normalize_input',
+ static function ( $input ) use ( &$calls ) {
+ return 2 === ++$calls ? new WP_Error( 'tracking_test_normalize' ) : $input;
+ }
+ );
+ $this->execute_result = function () {
+ wp_get_ability( self::ABILITY_ID )->execute();
+ return array( 'ok' => true );
+ };
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertEqualsCanonicalizing( array( 'none', 'tracking_test_normalize' ), array_column( $this->sut->recorded, 'error_code' ), 'The outer success and the inner normalization error should both be recorded' );
+ }
+
+ /**
+ * @testdox Should record long ability names in full.
+ */
+ public function test_records_long_ability_names_in_full(): void {
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::LONG_ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( 'woocommerce__tracking-test-with-a-long-name-that-goes-past-sixty-four-characters', $this->sut->recorded[0]['ability_name'] );
+ }
+
+ /**
+ * @testdox Should not record abilities outside the woocommerce namespace.
+ */
+ public function test_ignores_abilities_outside_woocommerce_namespace(): void {
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::UNTRACKED_ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( array(), $this->sut->recorded );
+ }
+
+ /**
+ * @testdox Should not record direct PHP calls outside MCP, the Abilities REST API and WP-CLI.
+ */
+ public function test_ignores_direct_php_calls(): void {
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( array(), $this->sut->recorded );
+ }
+
+ /**
+ * @testdox Should not attach any hooks on a single site when usage tracking is disabled.
+ */
+ public function test_does_not_attach_hooks_when_tracking_disabled(): void {
+ if ( is_multisite() ) {
+ $this->markTestSkipped( 'On multisite, hooks are always attached and consent is checked per call.' );
+ }
+ update_option( 'woocommerce_allow_tracking', 'no' );
+ $sut = new AbilitiesTracking();
+
+ $sut->handle_init();
+
+ $this->assertGreaterThan( 0, $this->count_hooked_callbacks( $this->sut ), 'The enabled instance from setUp should be hooked' );
+ $this->assertSame( 0, $this->count_hooked_callbacks( $sut ), 'Only the constructor init hook should be attached' );
+ }
+
+ /**
+ * @testdox Should cap the number of events recorded in one request.
+ */
+ public function test_caps_events_per_request(): void {
+ $this->start_mcp_tool_call();
+ $ability = wp_get_ability( self::ABILITY_ID );
+
+ for ( $i = 0; $i < 30; $i++ ) {
+ $ability->execute();
+ }
+ $this->sut->handle_shutdown();
+
+ $this->assertCount( 25, $this->sut->recorded );
+ $this->assertSame( 5, $this->sut->recorded[24]['events_dropped'], 'The last event should report how many were dropped' );
+ $this->assertArrayNotHasKey( 'events_dropped', $this->sut->recorded[0] );
+ }
+
+ /**
+ * @testdox Should keep closing hooks matched to their own call after many unclosed failures on WordPress 7.0.
+ */
+ public function test_keeps_frames_matched_after_many_unclosed_failures(): void {
+ $this->remove_instance_callbacks( $this->sut, self::WORDPRESS_7_1_HOOKS );
+ $this->start_mcp_tool_call();
+ $ability = wp_get_ability( self::ABILITY_ID );
+
+ $this->execute_result = new WP_Error( 'tracking_test_failure' );
+ for ( $i = 0; $i < 26; $i++ ) {
+ $ability->execute();
+ }
+ $this->execute_result = array( 'ok' => true );
+ $ability->execute();
+ $this->sut->handle_shutdown();
+
+ $outcomes = array_count_values( array_column( $this->sut->recorded, 'outcome' ) );
+ $this->assertSame( 1, $outcomes['success'] ?? 0, 'The successful call should be recorded as the only success' );
+ $this->assertSame( 2, end( $this->sut->recorded )['events_dropped'] );
+ }
+
+ /**
+ * @testdox Should send full buffers during long-running processes instead of dropping events.
+ */
+ public function test_flushes_full_buffer_in_long_running_process(): void {
+ $this->sut->long_running = true;
+ $this->start_mcp_tool_call();
+ $ability = wp_get_ability( self::ABILITY_ID );
+
+ for ( $i = 0; $i < 30; $i++ ) {
+ $ability->execute();
+ }
+
+ $this->assertCount( 25, $this->sut->recorded, 'The first full buffer should be sent before shutdown' );
+ $this->sut->handle_shutdown();
+ $this->assertCount( 30, $this->sut->recorded );
+ }
+
+ /**
+ * @testdox Should not record a call that turned usage tracking off.
+ */
+ public function test_does_not_record_call_that_disables_tracking(): void {
+ $this->execute_result = static function () {
+ update_option( 'woocommerce_allow_tracking', 'no' );
+ return array( 'ok' => true );
+ };
+ $this->start_mcp_tool_call();
+
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( array(), $this->sut->recorded );
+ }
+
+ /**
+ * @testdox Should not treat a posted onboarding opt-in as consent.
+ */
+ public function test_ignores_posted_onboarding_opt_in(): void {
+ update_option( 'woocommerce_allow_tracking', 'no' );
+ $_POST['wc_tracker_checkbox'] = 'yes';
+ $this->start_mcp_tool_call();
+
+ try {
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+ } finally {
+ unset( $_POST['wc_tracker_checkbox'] );
+ }
+
+ $this->assertSame( array(), $this->sut->recorded );
+ }
+
+ /**
+ * @testdox Should record calls on an opted-in site of a network when the request started on an opted-out site.
+ */
+ public function test_records_calls_after_switching_to_opted_in_site(): void {
+ if ( ! is_multisite() ) {
+ $this->markTestSkipped( 'Requires multisite.' );
+ }
+ $opted_in_site = self::factory()->blog->create();
+ switch_to_blog( $opted_in_site );
+ update_option( 'woocommerce_allow_tracking', 'yes' );
+ restore_current_blog();
+ update_option( 'woocommerce_allow_tracking', 'no' );
+ remove_all_actions( 'mcp_adapter_pre_tool_call' );
+ $sut = $this->create_sut();
+ $sut->handle_init();
+ $this->start_mcp_tool_call();
+ $ability = wp_get_ability( self::ABILITY_ID );
+
+ $ability->execute();
+ switch_to_blog( $opted_in_site );
+ try {
+ $ability->execute();
+ } finally {
+ restore_current_blog();
+ }
+ $sut->handle_shutdown();
+
+ $this->assertCount( 1, $sut->recorded, 'Only the call on the opted-in site should be recorded' );
+ }
+
+ /**
+ * @testdox Should not queue Tracks pixels for the unit-test user.
+ */
+ public function test_does_not_queue_pixels_for_unit_test_users(): void {
+ $queue = new \ReflectionProperty( \WC_Tracks_Client::class, 'pixel_batch_queue' );
+ $queue->setAccessible( true );
+ $before = count( $queue->getValue() );
+ $user = wp_get_current_user();
+ $cap_key = $user->cap_key;
+ $user->cap_key = 'wptests_capabilities';
+ $tracker = new AbilitiesTracking();
+ $tracker->handle_init();
+
+ try {
+ $this->start_mcp_tool_call();
+ wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+ $tracker->handle_shutdown();
+ } finally {
+ $user->cap_key = $cap_key;
+ $this->remove_instance_callbacks( $tracker );
+ }
+
+ $this->assertNotEmpty( $this->sut->recorded, 'The call should still be observed by the test double' );
+ $this->assertCount( $before, $queue->getValue() );
+ }
+
+ /**
+ * @testdox Should keep sending later events when one pixel send throws.
+ */
+ public function test_keeps_sending_after_one_event_fails(): void {
+ $this->sut->throw_on_send = 1;
+ $this->start_mcp_tool_call();
+ $ability = wp_get_ability( self::ABILITY_ID );
+
+ $ability->execute();
+ $ability->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( 2, $this->sut->sends );
+ $this->assertCount( 1, $this->sut->recorded );
+ }
+
+ /**
+ * @testdox Should not let a failing Tracks or consent filter break the ability call.
+ * @testWith ["woocommerce_tracks_event_properties"]
+ * ["woocommerce_apply_tracking"]
+ *
+ * @param string $hook Filter that throws.
+ */
+ public function test_tracking_failures_do_not_break_ability_calls( string $hook ): void {
+ add_filter(
+ $hook,
+ static function () {
+ throw new \RuntimeException( 'Broken filter.' );
+ }
+ );
+ $this->start_mcp_tool_call();
+
+ $result = wp_get_ability( self::ABILITY_ID )->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( array( 'ok' => true ), $result );
+ $this->assertSame( array(), $this->sut->recorded );
+ }
+
+ /**
+ * @testdox Should record each call with the user who made it.
+ */
+ public function test_records_user_context_of_each_call(): void {
+ $this->start_mcp_tool_call();
+ $ability = wp_get_ability( self::ABILITY_ID );
+
+ $ability->execute();
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+ $ability->execute();
+ $this->sut->handle_shutdown();
+
+ $this->assertSame( array( 'administrator', 'shop_manager' ), array_column( $this->sut->recorded, 'role' ) );
+ }
+
+ /**
+ * Create a tracker that captures events instead of sending them.
+ *
+ * @return AbilitiesTracking&object{recorded: array, long_running: bool}
+ */
+ private function create_sut(): AbilitiesTracking {
+ return new class() extends AbilitiesTracking {
+ /**
+ * Captured Tracks properties.
+ *
+ * @var array
+ */
+ public array $recorded = array();
+
+ /**
+ * Whether to behave as a long-running process.
+ *
+ * @var bool
+ */
+ public bool $long_running = false;
+
+ /**
+ * Send attempt that should throw, or 0 to send every event.
+ *
+ * @var int
+ */
+ public int $throw_on_send = 0;
+
+ /**
+ * How many sends have been attempted.
+ *
+ * @var int
+ */
+ public int $sends = 0;
+
+ /**
+ * Capture the event instead of sending it.
+ *
+ * @param array $properties Tracks properties.
+ */
+ protected function send_event( array $properties ): void {
+ ++$this->sends;
+ if ( $this->throw_on_send === $this->sends ) {
+ throw new \RuntimeException( 'pixel failed' );
+ }
+ $this->recorded[] = $properties;
+ }
+
+ /**
+ * Report the configured process type.
+ *
+ * @return bool
+ */
+ protected function is_long_running_process(): bool {
+ return $this->long_running;
+ }
+
+ /**
+ * Never send real pixels from tests; note when they would be sent.
+ */
+ protected function send_queued_pixels(): void {
+ $this->pixels_sent_after[] = count( $this->finished_after );
+ }
+
+ /**
+ * How many events had been sent each time the response was finished.
+ *
+ * @var int[]
+ */
+ public array $finished_after = array();
+
+ /**
+ * Note when the response would be finished.
+ */
+ protected function finish_response(): void {
+ $this->finished_after[] = count( $this->recorded );
+ }
+
+ /**
+ * How many times the response had been finished each time the queued pixels were sent.
+ *
+ * @var int[]
+ */
+ public array $pixels_sent_after = array();
+ };
+ }
+
+ /**
+ * Register a REST route whose callback runs the test ability.
+ *
+ * @param string $route_namespace Route namespace.
+ * @param string $route Route.
+ * @param string $method HTTP method.
+ */
+ private function register_route_that_runs_ability( string $route_namespace, string $route, string $method ): void {
+ global $wp_actions;
+
+ rest_get_server();
+ // register_rest_route() expects rest_api_init to have fired; firing it again would re-register every route.
+ $wp_actions['rest_api_init'] = max( 1, (int) ( $wp_actions['rest_api_init'] ?? 0 ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ register_rest_route(
+ $route_namespace,
+ $route,
+ array(
+ 'methods' => $method,
+ 'callback' => fn() => wp_get_ability( self::ABILITY_ID )->execute(),
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+
+ /**
+ * Simulate the MCP Adapter starting a tool call in this request.
+ */
+ private function start_mcp_tool_call(): void {
+ apply_filters( 'mcp_adapter_pre_tool_call', array(), 'mcp-adapter/execute-ability', null, null );
+ }
+
+ /**
+ * Skip the test on WordPress versions without hooks for every ability outcome (before 7.1).
+ */
+ private function skip_unless_wordpress_exposes_all_outcomes(): void {
+ if ( ! class_exists( 'WP_Filter_Sentinel' ) ) {
+ $this->markTestSkipped( 'WordPress 7.0 fires no hooks for failed ability calls.' );
+ }
+ }
+
+ /**
+ * Remove one instance's callbacks so later tests keep theirs.
+ *
+ * @param object $instance The instance.
+ * @param string[]|null $only_hooks Limit to these hooks, or null for every hook.
+ */
+ private function remove_instance_callbacks( object $instance, ?array $only_hooks = null ): void {
+ global $wp_filter;
+
+ $removals = array();
+ foreach ( $wp_filter as $hook_name => $hook ) {
+ if ( null !== $only_hooks && ! in_array( $hook_name, $only_hooks, true ) ) {
+ continue;
+ }
+ foreach ( $hook->callbacks as $priority => $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ if ( is_array( $callback['function'] ) && $instance === $callback['function'][0] ) {
+ $removals[] = array( $hook_name, $callback['function'], $priority );
+ }
+ }
+ }
+ }
+ foreach ( $removals as $removal ) {
+ remove_filter( $removal[0], $removal[1], $removal[2] );
+ }
+ }
+
+ /**
+ * Count callbacks of an instance attached to any hook, except the constructor's init hook.
+ *
+ * @param object $instance The instance.
+ * @return int
+ */
+ private function count_hooked_callbacks( object $instance ): int {
+ global $wp_filter;
+
+ $count = 0;
+ foreach ( $wp_filter as $hook_name => $hook ) {
+ foreach ( $hook->callbacks as $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ if ( is_array( $callback['function'] ) && $instance === $callback['function'][0] && 'init' !== $hook_name ) {
+ ++$count;
+ }
+ }
+ }
+ }
+ return $count;
+ }
+
+ /**
+ * Register the abilities used by these tests.
+ */
+ private function register_test_abilities(): void {
+ // Create the registry first; its creation fires wp_abilities_api_init, which would run the callback below twice.
+ wp_get_abilities();
+
+ if ( ! wp_has_ability_category( 'woocommerce' ) ) {
+ $register_category = static function (): void {
+ wp_register_ability_category(
+ 'woocommerce',
+ array(
+ 'label' => 'WooCommerce',
+ 'description' => 'Store management abilities.',
+ )
+ );
+ };
+ add_action( 'wp_abilities_api_categories_init', $register_category );
+ do_action( 'wp_abilities_api_categories_init' );
+ remove_action( 'wp_abilities_api_categories_init', $register_category );
+ }
+
+ $execute = function () {
+ return is_callable( $this->execute_result ) ? ( $this->execute_result )() : $this->execute_result;
+ };
+
+ $register_abilities = function () use ( $execute ): void {
+ $abilities = array(
+ self::ABILITY_ID => $execute,
+ self::INNER_ABILITY_ID => fn() => $this->inner_result,
+ self::UNTRACKED_ABILITY_ID => $execute,
+ self::LONG_ABILITY_ID => $execute,
+ self::NO_SCHEMA_ABILITY_ID => static fn() => array( 'ok' => true ),
+ );
+ foreach ( $abilities as $ability_id => $callback ) {
+ $args = array(
+ 'label' => 'Tracking test',
+ 'description' => 'Ability used by the tracking tests.',
+ 'category' => 'woocommerce',
+ 'execute_callback' => $callback,
+ 'permission_callback' => fn() => $this->permission,
+ 'meta' => array( 'show_in_rest' => true ),
+ );
+ if ( self::NO_SCHEMA_ABILITY_ID !== $ability_id ) {
+ $args['input_schema'] = array(
+ 'type' => 'object',
+ 'default' => array(),
+ );
+ }
+ wp_register_ability( $ability_id, $args );
+ }
+ };
+ add_action( 'wp_abilities_api_init', $register_abilities );
+ do_action( 'wp_abilities_api_init' );
+ remove_action( 'wp_abilities_api_init', $register_abilities );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/FakeMcpSessionManager.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/FakeMcpSessionManager.php
new file mode 100644
index 00000000000..37d72c05ab8
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/FakeMcpSessionManager.php
@@ -0,0 +1,32 @@
+<?php
+/**
+ * Fake MCP Adapter session manager class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+
+/**
+ * Stands in for WP\MCP\Transport\Infrastructure\SessionManager from the MCP Adapter plugin, which isn't installed in tests.
+ */
+class FakeMcpSessionManager {
+
+ /**
+ * Sessions by user ID and session ID.
+ *
+ * @var array<int, array<string, array>>
+ */
+ public static array $sessions = array();
+
+ /**
+ * Get a session, like the MCP Adapter does.
+ *
+ * @param int $user_id The user ID.
+ * @param string $session_id The session ID.
+ * @return array|false
+ */
+ public static function get_session( int $user_id, string $session_id ) {
+ return self::$sessions[ $user_id ][ $session_id ] ?? false;
+ }
+}