Commit 4999452afa3 for woocommerce
commit 4999452afa3f8b22a7710793ee8063db2f6f2b3e
Author: Christopher Hertel <mail@christopher-hertel.de>
Date: Mon Nov 2 16:03:00 2026 +0100
Add a payment gateways query ability without credentials (#70404)
* Add a payment gateways query ability without credentials
Lists each gateway's ID, checkout title, method title, description and
enabled state, or one gateway by ID. The output is built from the
gateway's public getters and never reads its settings, so API keys,
passwords and signatures cannot reach AI tools or MCP clients.
* Read gateway titles and descriptions without running checkout filters
get_title() and get_description() fire woocommerce_gateway_title and
woocommerce_gateway_description, which fee and surcharge plugins hook to
read the cart. REST, MCP and CLI requests have no cart, so read the
properties instead, as the payment gateways REST endpoint does.
Also drop the WC() guard: WooCommerce always defines it, and
payment_gateways() always returns the singleton.
* Report gateway titles, descriptions and enabled state as checkout does
Some gateways never set the title and description properties and read
them from settings in their getters, so the ability returned empty
strings for them. Fall back to the saved title and description setting;
no other setting is read.
Report enabled only for exactly 'yes', as is_available() does, instead of
any truthy string. Drop the WC_Payment_Gateway check, since the gateway
loader already skips anything else, and cover checkout order and the
closed item schema with tests.
* Document the payment gateways query ability in the MCP guide
* Describe empty gateway titles and descriptions without implementation details
The schema text is read by AI agents, which only need to know the field can be empty.
* Revert "Document the payment gateways query ability in the MCP guide"
This reverts commit 712c699fd537bd9b75aa817ac22d92edc209fa20.
diff --git a/plugins/woocommerce/changelog/wooai-1491-payment-gateways-ability b/plugins/woocommerce/changelog/wooai-1491-payment-gateways-ability
new file mode 100644
index 00000000000..7922c57370e
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-1491-payment-gateways-ability
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a payment gateways ability that lists each gateway's name, checkout title, description and enabled state, without credentials.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index 291775709dc..6f55aa49523 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderNotesQuery;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdateStatus;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrdersQuery;
+use Automattic\WooCommerce\Internal\Abilities\Domain\PaymentGatewaysQuery;
use Automattic\WooCommerce\Internal\Abilities\Domain\ProductCreate;
use Automattic\WooCommerce\Internal\Abilities\Domain\ProductDelete;
use Automattic\WooCommerce\Internal\Abilities\Domain\ProductUpdate;
@@ -43,6 +44,7 @@ class AbilitiesLoader {
OrderAddNote::class,
OrderNotesQuery::class,
OrderUpdateStatus::class,
+ PaymentGatewaysQuery::class,
ProductsQuery::class,
ProductCreate::class,
ProductDelete::class,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/PaymentGatewaysQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/PaymentGatewaysQuery.php
new file mode 100644
index 00000000000..9beacc436af
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/PaymentGatewaysQuery.php
@@ -0,0 +1,172 @@
+<?php
+/**
+ * Payment gateways query ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Lists payment gateways from their public properties. Gateway settings hold
+ * API keys and passwords, so only the saved title and description are read.
+ */
+class PaymentGatewaysQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+ /**
+ * Ability ID, stable once released.
+ *
+ * @return string
+ *
+ * @since 11.4.0
+ */
+ public static function get_name(): string {
+ return 'woocommerce/payment-gateways-query';
+ }
+
+ /**
+ * Read-only, shop-manager-level registration.
+ *
+ * @return array
+ *
+ * @since 11.4.0
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Query payment gateways', 'woocommerce' ),
+ 'description' => __(
+ 'List the payment gateways in checkout order, enabled or not, or get one by ID.',
+ 'woocommerce'
+ ),
+ 'category' => 'woocommerce',
+ 'input_schema' => self::get_input_schema(),
+ 'output_schema' => self::get_output_schema(),
+ 'execute_callback' => array( __CLASS__, 'execute' ),
+ 'permission_callback' => array( __CLASS__, 'can_query_payment_gateways' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => true,
+ 'idempotent' => true,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Gateways in the merchant's checkout order; one gateway when an ID is given.
+ *
+ * @param array $input Ability input.
+ * @return array|\WP_Error
+ *
+ * @since 11.4.0
+ */
+ public static function execute( array $input ) {
+ $gateways = WC()->payment_gateways()->payment_gateways();
+
+ if ( isset( $input['id'] ) ) {
+ $id = (string) $input['id'];
+
+ if ( ! isset( $gateways[ $id ] ) ) {
+ return new \WP_Error(
+ 'woocommerce_payment_gateway_not_found',
+ __( 'Payment gateway not found.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $gateways = array( $gateways[ $id ] );
+ }
+
+ $items = array();
+
+ foreach ( $gateways as $gateway ) {
+ // The title and description getters run checkout filters that may expect a cart.
+ $items[] = array(
+ 'id' => (string) $gateway->id,
+ 'title' => (string) ( $gateway->title ? $gateway->title : $gateway->get_option( 'title' ) ),
+ 'method_title' => (string) $gateway->get_method_title(),
+ 'description' => (string) ( $gateway->description ? $gateway->description : $gateway->get_option( 'description' ) ),
+ 'enabled' => 'yes' === $gateway->enabled,
+ );
+ }
+
+ return array(
+ 'gateways' => $items,
+ 'total_pages' => $items ? 1 : 0,
+ 'page' => 1,
+ 'per_page' => count( $items ),
+ );
+ }
+
+ /**
+ * Same check as the payment gateways REST endpoint.
+ *
+ * @return bool
+ *
+ * @since 11.4.0
+ */
+ public static function can_query_payment_gateways(): bool {
+ return wc_rest_check_manager_permissions( 'payment_gateways', 'read' );
+ }
+
+ /**
+ * Optional gateway ID; no paging, since stores have a handful of gateways.
+ *
+ * @return array
+ */
+ private static function get_input_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'string',
+ 'description' => __( 'Payment gateway ID, such as bacs or cod.', 'woocommerce' ),
+ 'minLength' => 1,
+ ),
+ ),
+ 'default' => array(),
+ 'additionalProperties' => false,
+ );
+ }
+
+ /**
+ * Closed item shape, so no settings field can be added to the output.
+ *
+ * @return array
+ */
+ private static function get_output_schema(): array {
+ return self::get_collection_output_schema(
+ 'gateways',
+ array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'string' ),
+ 'title' => array(
+ 'type' => 'string',
+ 'description' => __( 'Title shown to customers at checkout. Can be empty.', 'woocommerce' ),
+ ),
+ 'method_title' => array(
+ 'type' => 'string',
+ 'description' => __( 'Name of the payment method or provider.', 'woocommerce' ),
+ ),
+ 'description' => array(
+ 'type' => 'string',
+ 'description' => __( 'Description shown to customers at checkout. Can be empty.', 'woocommerce' ),
+ ),
+ 'enabled' => array( 'type' => 'boolean' ),
+ ),
+ 'additionalProperties' => false,
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index 598872a3cc9..428a9422553 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -40,6 +40,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
'woocommerce/order-update-status',
'woocommerce/order-add-note',
'woocommerce/order-notes-query',
+ 'woocommerce/payment-gateways-query',
);
/**
@@ -210,42 +211,47 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
*/
public function test_canonical_ability_annotations_match_intent(): void {
$expectations = array(
- 'woocommerce/products-query' => array(
+ 'woocommerce/products-query' => array(
'readonly' => true,
'idempotent' => true,
'destructive' => false,
),
- 'woocommerce/product-create' => array(
+ 'woocommerce/product-create' => array(
'readonly' => false,
'idempotent' => false,
'destructive' => false,
),
- 'woocommerce/product-update' => array(
+ 'woocommerce/product-update' => array(
'readonly' => false,
'idempotent' => false,
'destructive' => true,
),
- 'woocommerce/product-delete' => array(
+ 'woocommerce/product-delete' => array(
'readonly' => false,
'idempotent' => true,
'destructive' => true,
),
- 'woocommerce/orders-query' => array(
+ 'woocommerce/orders-query' => array(
'readonly' => true,
'idempotent' => true,
'destructive' => false,
),
- 'woocommerce/order-update-status' => array(
+ 'woocommerce/order-update-status' => array(
'readonly' => false,
'idempotent' => false,
'destructive' => true,
),
- 'woocommerce/order-add-note' => array(
+ 'woocommerce/order-add-note' => array(
'readonly' => false,
'idempotent' => false,
'destructive' => false,
),
- 'woocommerce/order-notes-query' => array(
+ 'woocommerce/order-notes-query' => array(
+ 'readonly' => true,
+ 'idempotent' => true,
+ 'destructive' => false,
+ ),
+ 'woocommerce/payment-gateways-query' => array(
'readonly' => true,
'idempotent' => true,
'destructive' => false,
@@ -298,8 +304,9 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
*/
public function test_collection_output_schemas_describe_pagination_metadata(): void {
$schemas = array(
- 'woocommerce/products-query' => 'products',
- 'woocommerce/orders-query' => 'orders',
+ 'woocommerce/products-query' => 'products',
+ 'woocommerce/orders-query' => 'orders',
+ 'woocommerce/payment-gateways-query' => 'gateways',
);
foreach ( $schemas as $ability_id => $collection_key ) {
@@ -867,57 +874,61 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
public function provider_canonical_abilities_with_minimal_input(): array {
// Valid-shape input only — permission check should fire before execute.
return array(
- 'products-query' => array(
+ 'products-query' => array(
'woocommerce/products-query',
array(
'id' => 1,
),
),
- 'product-create' => array(
+ 'product-create' => array(
'woocommerce/product-create',
array(
'name' => 'Forbidden',
),
),
- 'product-update' => array(
+ 'product-update' => array(
'woocommerce/product-update',
array(
'id' => 1,
'name' => 'Forbidden',
),
),
- 'product-delete' => array(
+ 'product-delete' => array(
'woocommerce/product-delete',
array(
'id' => 1,
),
),
- 'orders-query' => array(
+ 'orders-query' => array(
'woocommerce/orders-query',
array(
'id' => 1,
),
),
- 'order-update-status' => array(
+ 'order-update-status' => array(
'woocommerce/order-update-status',
array(
'id' => 1,
'status' => 'processing',
),
),
- 'order-add-note' => array(
+ 'order-add-note' => array(
'woocommerce/order-add-note',
array(
'id' => 1,
'note' => 'denied',
),
),
- 'order-notes-query' => array(
+ 'order-notes-query' => array(
'woocommerce/order-notes-query',
array(
'id' => 1,
),
),
+ 'payment-gateways-query' => array(
+ 'woocommerce/payment-gateways-query',
+ array(),
+ ),
);
}
@@ -2689,6 +2700,248 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$this->assertSame( '1', get_comment_meta( $result['note_id'], 'is_customer_note', true ) );
}
+ /**
+ * @testdox Should never return the API credentials of a configured gateway.
+ */
+ public function test_payment_gateways_query_omits_gateway_credentials(): void {
+ update_option(
+ 'woocommerce_paypal_settings',
+ array(
+ '_should_load' => 'yes',
+ 'enabled' => 'yes',
+ 'title' => 'PayPal',
+ 'api_username' => 'live-api-username',
+ 'api_password' => 'live-api-password',
+ 'api_signature' => 'live-api-signature',
+ 'sandbox_api_username' => 'sandbox-api-username',
+ 'sandbox_api_password' => 'sandbox-api-password',
+ 'sandbox_api_signature' => 'sandbox-api-signature',
+ 'identity_token' => 'pdt-identity-token',
+ )
+ );
+
+ try {
+ self::reload_payment_gateways();
+
+ $result = wp_get_ability( 'woocommerce/payment-gateways-query' )->execute( array( 'id' => 'paypal' ) );
+ } finally {
+ delete_option( 'woocommerce_paypal_settings' );
+ self::reload_payment_gateways();
+ }
+
+ $this->assertNotWPError( $result );
+ $this->assertCount( 1, $result['gateways'] );
+ $this->assertSame(
+ array( 'id', 'title', 'method_title', 'description', 'enabled' ),
+ array_keys( $result['gateways'][0] )
+ );
+ $this->assertTrue( $result['gateways'][0]['enabled'] );
+
+ $encoded = wp_json_encode( $result );
+ foreach ( array( 'api-username', 'api-password', 'api-signature', 'pdt-identity-token' ) as $secret ) {
+ $this->assertStringNotContainsString( $secret, $encoded, "Output should not contain the {$secret} setting." );
+ }
+ }
+
+ /**
+ * @testdox Should list every loaded gateway, enabled or not, with a boolean enabled flag.
+ */
+ public function test_payment_gateways_query_lists_loaded_gateways(): void {
+ update_option( 'woocommerce_bacs_settings', array( 'enabled' => 'yes' ) );
+ update_option( 'woocommerce_cheque_settings', array( 'enabled' => 'no' ) );
+
+ try {
+ self::reload_payment_gateways();
+
+ $result = wp_get_ability( 'woocommerce/payment-gateways-query' )->execute( array() );
+ } finally {
+ delete_option( 'woocommerce_bacs_settings' );
+ delete_option( 'woocommerce_cheque_settings' );
+ self::reload_payment_gateways();
+ }
+
+ $this->assertNotWPError( $result );
+
+ $enabled_by_id = array_column( $result['gateways'], 'enabled', 'id' );
+ $this->assertTrue( $enabled_by_id['bacs'] );
+ $this->assertFalse( $enabled_by_id['cheque'] );
+ $this->assertArrayHasKey( 'cod', $enabled_by_id );
+ $this->assertSame( 'Direct bank transfer', array_column( $result['gateways'], 'method_title', 'id' )['bacs'] );
+ $this->assertSame( 1, $result['total_pages'] );
+ $this->assertSame( count( $result['gateways'] ), $result['per_page'] );
+ }
+
+ /**
+ * @testdox Should read gateway titles and descriptions without running the checkout filters.
+ */
+ public function test_payment_gateways_query_skips_checkout_title_and_description_filters(): void {
+ $filter = static function () {
+ return 'filtered';
+ };
+ add_filter( 'woocommerce_gateway_title', $filter );
+ add_filter( 'woocommerce_gateway_description', $filter );
+
+ try {
+ $result = wp_get_ability( 'woocommerce/payment-gateways-query' )->execute( array( 'id' => 'bacs' ) );
+ } finally {
+ remove_filter( 'woocommerce_gateway_title', $filter );
+ remove_filter( 'woocommerce_gateway_description', $filter );
+ }
+
+ $this->assertNotWPError( $result );
+ $this->assertNotSame( 'filtered', $result['gateways'][0]['title'] );
+ $this->assertNotSame( 'filtered', $result['gateways'][0]['description'] );
+ }
+
+ /**
+ * @testdox Should fall back to the saved title and description when a gateway leaves the properties unset.
+ */
+ public function test_payment_gateways_query_falls_back_to_saved_title_and_description(): void {
+ $gateway = new class() extends \WC_Payment_Gateway {
+ /**
+ * Leaves title and description unset, like gateways that read them from settings in their getters.
+ */
+ public function __construct() {
+ $this->id = 'settings_only_gateway';
+ $this->form_fields = array(
+ 'title' => array( 'type' => 'text' ),
+ 'description' => array( 'type' => 'textarea' ),
+ 'api_key' => array( 'type' => 'password' ),
+ );
+ }
+ };
+ update_option(
+ 'woocommerce_settings_only_gateway_settings',
+ array(
+ 'enabled' => 'yes',
+ 'title' => 'Saved title',
+ 'description' => 'Saved description',
+ 'api_key' => 'saved-api-key',
+ )
+ );
+ $register = static function ( $gateways ) use ( $gateway ) {
+ $gateways[] = $gateway;
+ return $gateways;
+ };
+ add_filter( 'woocommerce_payment_gateways', $register );
+
+ try {
+ self::reload_payment_gateways();
+
+ $result = wp_get_ability( 'woocommerce/payment-gateways-query' )->execute( array( 'id' => 'settings_only_gateway' ) );
+ } finally {
+ remove_filter( 'woocommerce_payment_gateways', $register );
+ delete_option( 'woocommerce_settings_only_gateway_settings' );
+ self::reload_payment_gateways();
+ }
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( 'Saved title', $result['gateways'][0]['title'] );
+ $this->assertSame( 'Saved description', $result['gateways'][0]['description'] );
+ $this->assertStringNotContainsString( 'saved-api-key', wp_json_encode( $result ) );
+ }
+
+ /**
+ * @testdox Should report a gateway as enabled only when checkout would, that is when enabled is exactly yes.
+ */
+ public function test_payment_gateways_query_reports_enabled_like_checkout(): void {
+ $gateway = new class() extends \WC_Payment_Gateway {
+ /**
+ * Assigns a truthy value other than yes, which checkout treats as disabled.
+ */
+ public function __construct() {
+ $this->id = 'truthy_enabled_gateway';
+ $this->title = 'Truthy';
+ $this->enabled = 'true';
+ }
+ };
+ $register = static function ( $gateways ) use ( $gateway ) {
+ $gateways[] = $gateway;
+ return $gateways;
+ };
+ add_filter( 'woocommerce_payment_gateways', $register );
+
+ try {
+ self::reload_payment_gateways();
+
+ $result = wp_get_ability( 'woocommerce/payment-gateways-query' )->execute( array( 'id' => 'truthy_enabled_gateway' ) );
+ } finally {
+ remove_filter( 'woocommerce_payment_gateways', $register );
+ self::reload_payment_gateways();
+ }
+
+ $this->assertNotWPError( $result );
+ $this->assertFalse( $result['gateways'][0]['enabled'] );
+ }
+
+ /**
+ * @testdox Should list gateways in the merchant's checkout order.
+ */
+ public function test_payment_gateways_query_follows_checkout_order(): void {
+ update_option(
+ 'woocommerce_gateway_order',
+ array(
+ 'cod' => 0,
+ 'cheque' => 1,
+ 'bacs' => 2,
+ )
+ );
+
+ try {
+ self::reload_payment_gateways();
+
+ $result = wp_get_ability( 'woocommerce/payment-gateways-query' )->execute( array() );
+ } finally {
+ delete_option( 'woocommerce_gateway_order' );
+ self::reload_payment_gateways();
+ }
+
+ $this->assertNotWPError( $result );
+ $this->assertSame(
+ array( 'cod', 'cheque', 'bacs' ),
+ array_values( array_intersect( array_column( $result['gateways'], 'id' ), array( 'bacs', 'cheque', 'cod' ) ) )
+ );
+ }
+
+ /**
+ * @testdox Should close the gateway item schema, so no settings field can be added to the output.
+ */
+ public function test_payment_gateways_query_item_schema_is_closed(): void {
+ $items = wp_get_ability( 'woocommerce/payment-gateways-query' )->get_output_schema()['properties']['gateways']['items'];
+
+ $this->assertFalse( $items['additionalProperties'] );
+ }
+
+ /**
+ * @testdox Should let a shop manager query payment gateways.
+ */
+ public function test_payment_gateways_query_allows_shop_manager(): void {
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'shop_manager' ) ) );
+
+ $result = wp_get_ability( 'woocommerce/payment-gateways-query' )->execute( array( 'id' => 'bacs' ) );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( 'bacs', $result['gateways'][0]['id'] );
+ }
+
+ /**
+ * @testdox Should return a not-found error for an unknown payment gateway ID.
+ */
+ public function test_payment_gateways_query_returns_not_found_for_unknown_id(): void {
+ $result = wp_get_ability( 'woocommerce/payment-gateways-query' )->execute( array( 'id' => 'no-such-gateway' ) );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_payment_gateway_not_found', $result->get_error_code() );
+ }
+
+ /**
+ * Rebuild the loaded payment gateways from the current options, since init() only appends.
+ */
+ private static function reload_payment_gateways(): void {
+ WC()->payment_gateways()->payment_gateways = array();
+ WC()->payment_gateways()->init();
+ }
+
/**
* Register the WooCommerce ability category for this test.
*/