Commit 50f65fe7e7d for woocommerce

commit 50f65fe7e7d7fb9ca693882ccc4e1da5e1ac10a0
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date:   Wed Oct 21 10:01:18 2026 +0200

    Track MCP Adapter discovery as wcadmin_ability_discovered (#69971)

    * Track MCP Adapter discovery as wcadmin_ability_discovered

    On the MCP Adapter's default server, agents list abilities
    (mcp-adapter/discover-abilities) and look one up (mcp-adapter/get-ability-info)
    before running it. Those calls were skipped with every mcp-adapter/* ability,
    so only executions were visible.

    A successful get-ability-info for a tracked ability now records
    wcadmin_ability_discovered with discovery=info and that ability's name, and
    discover-abilities records discovery=list with ability_name=none. It is a
    separate event so wcadmin_ability_called stays one row per call. Both events
    share one helper for the common properties and guards.

    * Lock the unregistered-ability discovery guard with a test

    A woocommerce/ name that is not registered must not be recorded.

    * Leave real MCP Adapter abilities alone in the tracking tests

    Register the discovery stand-ins only when the Adapter's abilities are not already registered, and unregister only the ones the test added.

diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
index af7a4addc1e..bf8155ab9d5 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
@@ -17,7 +17,8 @@ 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.
+ * Tracks client. Ability input and output are never recorded, except the name of a registered ability an agent
+ * looks up through the MCP Adapter.
  *
  * 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
@@ -34,6 +35,20 @@ class AbilitiesTracking {
 	 */
 	private const EVENT_NAME = 'ability_called';

+	/**
+	 * Tracks event for agents looking up abilities through the MCP Adapter, without the `wcadmin_` prefix.
+	 *
+	 * @var string
+	 */
+	private const DISCOVERY_EVENT_NAME = 'ability_discovered';
+
+	/**
+	 * Channels whose calls are recorded: agent entry points, not plugin internals.
+	 *
+	 * @var string[]
+	 */
+	private const TRACKED_CHANNELS = array( 'mcp', 'rest', 'cli' );
+
 	/**
 	 * Only abilities in this namespace are tracked.
 	 *
@@ -170,7 +185,7 @@ class AbilitiesTracking {
 		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_action( 'wp_after_execute_ability', array( $this, 'handle_wp_after_execute_ability' ), 10, 2 );
 		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 );
@@ -336,9 +351,11 @@ class AbilitiesTracking {
 	 * @internal
 	 *
 	 * @param string $ability_name The ability name.
+	 * @param mixed  $input        The input. Only read for MCP Adapter discovery.
 	 */
-	public function handle_wp_after_execute_ability( $ability_name ): void {
+	public function handle_wp_after_execute_ability( $ability_name, $input = null ): void {
 		$this->close_frame( $ability_name, true, null );
+		$this->record_discovery( $ability_name, $input );
 	}

 	/**
@@ -538,7 +555,7 @@ class AbilitiesTracking {
 		}

 		// 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 ) ) {
+		if ( ! in_array( $this->get_channel(), self::TRACKED_CHANNELS, true ) ) {
 			return;
 		}

@@ -624,15 +641,79 @@ class AbilitiesTracking {
 	}

 	/**
-	 * 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.
+	 * Queue a finished invocation as an ability_called event.
 	 *
 	 * @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 {
+		$error_code = self::NONE;
+		if ( $outcome instanceof WP_Error ) {
+			$error_code = self::get_error_code( $outcome );
+		} elseif ( null === $outcome ) {
+			$error_code = 'unknown';
+		}
+
+		$event = array(
+			'ability_name'    => self::get_event_ability_name( $frame['name'] ),
+			'outcome'         => true === $outcome ? 'success' : 'error',
+			'error_code'      => $error_code,
+			'channel'         => $frame['channel'],
+			'rest_namespace'  => $frame['rest_namespace'],
+			'external_client' => $frame['external_client'],
+		);
+		if ( $has_duration ) {
+			$event['duration_ms'] = (int) round( ( microtime( true ) - $frame['start'] ) * 1000 );
+		}
+
+		// Unknown failures are recorded later than the call (WordPress 7.0 has no hook for them), so they keep the call's
+		// start time. Site and user are still read now; they differ only if the request switched them since.
+		$this->queue_event( self::EVENT_NAME, $event, null === $outcome ? $frame['start'] : null );
+	}
+
+	/**
+	 * Record an agent looking up abilities through the MCP Adapter: `get-ability-info` for a tracked ability, or
+	 * `discover-abilities`. A separate event, so ability_called stays one row per call. Successful lookups only.
+	 *
+	 * @param mixed $ability_name The ability that ran.
+	 * @param mixed $input        Its input.
+	 */
+	private function record_discovery( $ability_name, $input ): void {
+		if ( 'mcp-adapter/discover-abilities' === $ability_name ) {
+			$looked_up = null;
+		} elseif ( 'mcp-adapter/get-ability-info' === $ability_name && is_array( $input ) && self::is_tracked_ability( $input['ability_name'] ?? null ) && wp_has_ability( $input['ability_name'] ) ) {
+			$looked_up = $input['ability_name'];
+		} else {
+			return;
+		}
+
+		$channel = $this->get_channel();
+		if ( ! in_array( $channel, self::TRACKED_CHANNELS, true ) ) {
+			return;
+		}
+
+		$this->queue_event(
+			self::DISCOVERY_EVENT_NAME,
+			array(
+				'ability_name'    => null === $looked_up ? self::NONE : self::get_event_ability_name( $looked_up ),
+				'discovery'       => null === $looked_up ? 'list' : 'info',
+				'channel'         => $channel,
+				'external_client' => 'mcp' === $channel ? $this->get_external_client() : self::NONE,
+			)
+		);
+	}
+
+	/**
+	 * Add the properties every event carries and queue the event for sending.
+	 *
+	 * Properties are built now, so the user, site and time are those of the call rather than of shutdown.
+	 *
+	 * @param string               $event_name Event name, without the `wcadmin_` prefix.
+	 * @param array<string, mixed> $event      Event-specific properties.
+	 * @param float|null           $started    microtime() to record as the event time, or null for now.
+	 */
+	private function queue_event( string $event_name, array $event, ?float $started = null ): 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 ) {
@@ -655,38 +736,16 @@ class AbilitiesTracking {
 				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 );
-			}
+			$event['auth'] = $auth;
 			// 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 );
+			$properties = \WC_Tracks::get_properties( \WC_Tracks::PREFIX . $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, '', '' );
+			if ( null !== $started ) {
+				$properties['_ts'] = number_format( round( $started * 1000 ), 0, '', '' );
 			}

 			$this->events[] = $properties;
@@ -695,6 +754,17 @@ class AbilitiesTracking {
 		}
 	}

+	/**
+	 * Ability name as recorded: the `/` becomes `__`, and hyphens stay, so `products-query` and `products_query` stay
+	 * different events.
+	 *
+	 * @param string $ability_name Ability name.
+	 * @return string
+	 */
+	private static function get_event_ability_name( string $ability_name ): string {
+		return implode( '__', explode( '/', strtolower( $ability_name ), 2 ) );
+	}
+
 	/**
 	 * Identify how the ability was invoked.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
index 3cd1765e9b3..bf6ac285d61 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
@@ -23,6 +23,11 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {

 	private const NO_SCHEMA_ABILITY_ID = 'woocommerce/tracking-test-no-schema';

+	/**
+	 * The MCP Adapter's discovery abilities. The tests register stand-ins when the Adapter isn't loaded.
+	 */
+	private const ADAPTER_DISCOVERY_IDS = array( 'mcp-adapter/get-ability-info', 'mcp-adapter/discover-abilities' );
+
 	private const SERVER_KEYS = array( 'REQUEST_SCHEME', 'HTTP_HOST', 'REQUEST_URI', 'HTTP_REFERER', 'HTTP_MCP_PROTOCOL_VERSION', 'HTTP_MCP_SESSION_ID', 'A8C_PROXIED_REQUEST' );

 	/**
@@ -89,6 +94,13 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
 	 */
 	private array $original_server = array();

+	/**
+	 * Adapter discovery stand-ins this test registered, so real MCP Adapter abilities are left alone.
+	 *
+	 * @var string[]
+	 */
+	private array $registered_adapter_ids = array();
+
 	/**
 	 * Create shared fixtures.
 	 *
@@ -141,7 +153,7 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
 			}

 			// 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 ) {
+			foreach ( array_merge( array( self::ABILITY_ID, self::INNER_ABILITY_ID, self::UNTRACKED_ABILITY_ID, self::LONG_ABILITY_ID, self::NO_SCHEMA_ABILITY_ID ), $this->registered_adapter_ids ) as $ability_id ) {
 				if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_id ) ) {
 					wp_unregister_ability( $ability_id );
 				}
@@ -406,6 +418,41 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
 		$this->assertSame( 'mcp', $this->sut->recorded[0]['channel'] );
 	}

+	/**
+	 * @testdox Should record MCP Adapter discovery of WooCommerce abilities as a separate event.
+	 * @testWith ["mcp-adapter/get-ability-info", "woocommerce/tracking-test", true, "info", "woocommerce__tracking-test"]
+	 *           ["mcp-adapter/discover-abilities", "", true, "list", "none"]
+	 *           ["mcp-adapter/get-ability-info", "tracking-test/other", true, "", ""]
+	 *           ["mcp-adapter/get-ability-info", "woocommerce/not-registered", true, "", ""]
+	 *           ["mcp-adapter/get-ability-info", "woocommerce/tracking-test", false, "", ""]
+	 *
+	 * @param string $adapter_ability   Adapter ability the agent calls.
+	 * @param string $looked_up         Ability it asks about, or '' for none.
+	 * @param bool   $over_mcp          Whether the call comes through MCP rather than a direct PHP call.
+	 * @param string $discovery         Expected discovery property, or '' when nothing is recorded.
+	 * @param string $recorded_ability  Expected ability_name.
+	 */
+	public function test_records_adapter_discovery( string $adapter_ability, string $looked_up, bool $over_mcp, string $discovery, string $recorded_ability ): void {
+		if ( $over_mcp ) {
+			$this->start_mcp_tool_call();
+		}
+
+		wp_get_ability( $adapter_ability )->execute( '' === $looked_up ? array() : array( 'ability_name' => $looked_up ) );
+		$this->sut->handle_shutdown();
+
+		if ( '' === $discovery ) {
+			$this->assertSame( array(), $this->sut->recorded, 'Unregistered names, other plugins, and direct PHP calls are not recorded' );
+			return;
+		}
+		$this->assertCount( 1, $this->sut->recorded, 'Only the discovery event; the adapter ability itself is not an ability call' );
+		$event = $this->sut->recorded[0];
+		$this->assertSame( 'wcadmin_ability_discovered', $event['_en'] );
+		$this->assertSame( $discovery, $event['discovery'] );
+		$this->assertSame( $recorded_ability, $event['ability_name'] );
+		$this->assertSame( 'mcp', $event['channel'] );
+		$this->assertSame( 'none', $event['external_client'], 'No MCP session, so the client is unknown' );
+	}
+
 	/**
 	 * @testdox Should not report Jetpack authentication for signature arguments Jetpack didn't verify.
 	 */
@@ -1241,6 +1288,26 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
 				}
 				wp_register_ability( $ability_id, $args );
 			}
+			foreach ( self::ADAPTER_DISCOVERY_IDS as $ability_id ) {
+				if ( wp_has_ability( $ability_id ) ) {
+					continue;
+				}
+				$this->registered_adapter_ids[] = $ability_id;
+				wp_register_ability(
+					$ability_id,
+					array(
+						'label'               => 'Adapter discovery',
+						'description'         => 'Stand-in for an MCP Adapter discovery ability.',
+						'category'            => 'woocommerce',
+						'input_schema'        => array(
+							'type'    => 'object',
+							'default' => array(),
+						),
+						'execute_callback'    => static fn() => array( 'ok' => true ),
+						'permission_callback' => '__return_true',
+					)
+				);
+			}
 		};
 		add_action( 'wp_abilities_api_init', $register_abilities );
 		do_action( 'wp_abilities_api_init' );