wordpress-agent-kit 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/skills/blueprint/SKILL.md +418 -0
- package/.github/skills/wp-abilities-api/SKILL.md +12 -0
- package/.github/skills/wp-abilities-api/references/delegate-helper-pattern.md +241 -0
- package/.github/skills/wp-abilities-api/references/domain-vs-projection.md +113 -0
- package/.github/skills/wp-abilities-api/references/error-code-vocabulary.md +123 -0
- package/.github/skills/wp-abilities-api/references/grouping-heuristic.md +89 -0
- package/.github/skills/wp-abilities-api/references/input-schema-gotchas.md +265 -0
- package/.github/skills/wp-abilities-api/references/php-registration.md +47 -20
- package/.github/skills/wp-abilities-api/references/plugin-family-patterns.md +233 -0
- package/.github/skills/wp-abilities-api/references/shared-core-service.md +184 -0
- package/.github/skills/wp-abilities-audit/SKILL.md +199 -0
- package/.github/skills/wp-abilities-audit/references/audit-schema.md +300 -0
- package/.github/skills/wp-abilities-audit/references/capability-gate-tracing.md +197 -0
- package/.github/skills/wp-abilities-audit/references/controller-enumeration.md +116 -0
- package/.github/skills/wp-abilities-verify/SKILL.md +215 -0
- package/.github/skills/wp-abilities-verify/references/annotation-correctness.md +154 -0
- package/.github/skills/wp-abilities-verify/references/audit-schema-validation.md +131 -0
- package/.github/skills/wp-abilities-verify/references/permission-roundtrip.md +190 -0
- package/.github/skills/wp-abilities-verify/references/runtime-harness.md +462 -0
- package/.github/skills/wp-abilities-verify/references/schema-lints.md +118 -0
- package/.github/skills/wp-abilities-verify/references/static-enumeration.md +126 -0
- package/.github/skills/wp-playground/SKILL.md +132 -1
- package/.github/skills/wp-playground/references/e2e-playwright.md +115 -0
- package/.github/skills/wp-plugin-directory-guidelines/SKILL.md +133 -0
- package/.github/skills/wp-plugin-directory-guidelines/references/gpl-compliance.md +217 -0
- package/.github/skills/wp-plugin-directory-guidelines/references/guideline-review-checklist.md +592 -0
- package/.github/skills/wp-plugin-directory-guidelines/references/naming-rules.md +121 -0
- package/.github/skills/wp-project-triage/scripts/detect_wp_project.mjs +22 -4
- package/.github/skills/wp-wpengine/SKILL.md +127 -0
- package/README.md +14 -8
- package/dist/lib/api.js +43 -19
- package/dist/lib/installer.js +0 -2
- package/extensions/wp-agent-kit/index.ts +146 -324
- package/package.json +1 -1
- package/skills-custom/wp-wpengine/SKILL.md +127 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Input schema gotchas
|
|
2
|
+
|
|
3
|
+
Three surprises the Abilities API's `input_schema` will ship with if you rely on schema declarations alone. All three have been caught in real plugin work after the schema looked correct and tests passed; each has a defensive pattern that makes the execute callback robust regardless.
|
|
4
|
+
|
|
5
|
+
## 1. Schema `default` values are NOT injected into execute callback input
|
|
6
|
+
|
|
7
|
+
### Problem
|
|
8
|
+
|
|
9
|
+
`wp_register_ability()` lets you declare per-property defaults in `input_schema`:
|
|
10
|
+
|
|
11
|
+
```php
|
|
12
|
+
'input_schema' => [
|
|
13
|
+
'type' => 'object',
|
|
14
|
+
'properties' => [
|
|
15
|
+
'submit' => [
|
|
16
|
+
'type' => 'boolean',
|
|
17
|
+
'default' => false,
|
|
18
|
+
'description' => __( 'Whether to submit evidence for review.', '<text-domain>' ),
|
|
19
|
+
],
|
|
20
|
+
],
|
|
21
|
+
],
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The Abilities API's validate path enforces `type` and `required`, but it does NOT populate missing properties with their declared `default`. If an agent invokes the ability with `{ dispute_id: "dp_..." }` and omits `submit`, the execute callback receives `$input` **without** a `submit` key — it does not get `$input['submit'] = false`.
|
|
25
|
+
|
|
26
|
+
### Symptoms
|
|
27
|
+
|
|
28
|
+
- Boolean defaults silently become "undefined" in the callback and any `if ( $input['submit'] )` check compares against `null`, which works by accident but fails `isset` checks and strict-type branches.
|
|
29
|
+
- Integer pagination defaults like `'page' => [ 'default' => 1 ]` never reach the backing controller, so pagination falls back to the controller's internal default rather than the one declared in the schema.
|
|
30
|
+
- Object defaults (`'metadata' => [ 'default' => [] ]`) become `null` rather than `[]` and any `foreach ( $input['metadata'] as ... )` hits a type error.
|
|
31
|
+
|
|
32
|
+
### Fix — normalize defaults explicitly in the execute callback
|
|
33
|
+
|
|
34
|
+
```php
|
|
35
|
+
public static function execute_submit_evidence( $input = null ) {
|
|
36
|
+
if ( ! is_array( $input ) ) {
|
|
37
|
+
$input = [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Apply schema defaults the Abilities API does not inject.
|
|
41
|
+
if ( ! array_key_exists( 'submit', $input ) || null === $input['submit'] ) {
|
|
42
|
+
$input['submit'] = false;
|
|
43
|
+
}
|
|
44
|
+
$input['submit'] = (bool) $input['submit'];
|
|
45
|
+
|
|
46
|
+
if ( ! array_key_exists( 'metadata', $input ) || null === $input['metadata'] ) {
|
|
47
|
+
$input['metadata'] = [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ... rest of callback
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The `array_key_exists` + null-check pattern catches both "missing key" and "explicit null" (some serializers produce nulls for optional fields).
|
|
55
|
+
|
|
56
|
+
Keep the declared `default` in `input_schema` anyway — it documents the expected behavior for anyone reading the registration and is visible to agents in the schema introspection endpoints. Just don't rely on it for runtime population.
|
|
57
|
+
|
|
58
|
+
## 2. Pagination parameter-name drift
|
|
59
|
+
|
|
60
|
+
### Problem
|
|
61
|
+
|
|
62
|
+
The `input_schema` convention across most WordPress REST endpoints is `per_page` (matching WP core REST list endpoints and `WP_REST_Controller`'s `get_collection_params()`). Some plugin REST controllers, however, delegate to an internal request-builder class that reads a different key (e.g. `pagesize` or `page_size`), typically for historical reasons.
|
|
63
|
+
|
|
64
|
+
If the ability's `input_schema` exposes `per_page` and the backing controller reads `pagesize`, the agent's `per_page: 50` silently never reaches pagination — the value is accepted, forwarded verbatim to the backing, and then ignored. Agents keep getting the default page size and suspect their filter is broken.
|
|
65
|
+
|
|
66
|
+
### Symptoms
|
|
67
|
+
|
|
68
|
+
- List abilities return the backing controller's default page size regardless of the agent's `per_page` input.
|
|
69
|
+
- Integration tests that only check "a list came back" pass; only a test asserting `count( $response['data'] )` catches it.
|
|
70
|
+
- Harness runs show the raw response count matching the default (25, 10, etc.) for every call.
|
|
71
|
+
|
|
72
|
+
### Detection heuristic
|
|
73
|
+
|
|
74
|
+
Before shipping a paginated ability, grep the backing controller's call chain:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Check the backing controller and anything it delegates to.
|
|
78
|
+
grep -rn "pagesize\|page_size" <path/to/backing-controller.php> <path/to/request-helpers/>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If `pagesize` (or any non-`per_page` key) appears in the chain and the ability's schema exposes `per_page`, the execute callback MUST translate.
|
|
82
|
+
|
|
83
|
+
### Fix — translate before delegating
|
|
84
|
+
|
|
85
|
+
```php
|
|
86
|
+
/**
|
|
87
|
+
* Translate pagination keys for abilities whose backing reads a non-standard key.
|
|
88
|
+
*
|
|
89
|
+
* Abilities expose `per_page` uniformly for agent-facing consistency; this
|
|
90
|
+
* helper rewrites it to whatever key the backing actually reads.
|
|
91
|
+
*
|
|
92
|
+
* @param array|null $input Ability input (or null).
|
|
93
|
+
* @return array|null Input with `per_page` rewritten to `<backing_key>` when present.
|
|
94
|
+
*/
|
|
95
|
+
private static function translate_pagination_keys( $input ) {
|
|
96
|
+
if ( ! is_array( $input ) ) {
|
|
97
|
+
return $input;
|
|
98
|
+
}
|
|
99
|
+
if ( isset( $input['per_page'] ) ) {
|
|
100
|
+
$input['<backing_key>'] = $input['per_page'];
|
|
101
|
+
unset( $input['per_page'] );
|
|
102
|
+
}
|
|
103
|
+
return $input;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
public static function execute_get_things( $input = null ) {
|
|
107
|
+
$input = self::translate_pagination_keys( is_array( $input ) ? $input : null );
|
|
108
|
+
|
|
109
|
+
return self::delegate_to_rest_controller(
|
|
110
|
+
'<Backing_Controller_Class>',
|
|
111
|
+
'get_things',
|
|
112
|
+
'/my-plugin/v1/things',
|
|
113
|
+
$input
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Place the translation BEFORE the delegate call so `per_page` never reaches the backing.
|
|
119
|
+
|
|
120
|
+
### When NOT to apply this
|
|
121
|
+
|
|
122
|
+
Do not apply blindly. If the backing reads `per_page` directly (most modern WP REST controllers do), adding this translation would silently break pagination by renaming the key to something the backing doesn't understand.
|
|
123
|
+
|
|
124
|
+
**Always grep the backing first.** The translation is only correct for a specific backing chain; it's not a general safety net.
|
|
125
|
+
|
|
126
|
+
## 3. ID validation must not use `empty()`
|
|
127
|
+
|
|
128
|
+
### Problem
|
|
129
|
+
|
|
130
|
+
PHP's `empty()` is permissive in ways that bite ID validation:
|
|
131
|
+
|
|
132
|
+
```php
|
|
133
|
+
empty( '0' ) // true — but "0" is a legal string ID (order ID, row ID, post ID in some code paths)
|
|
134
|
+
empty( 0 ) // true — same for integer zero
|
|
135
|
+
empty( [] ) // true — expected, but same keyword used for different cases
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
An execute callback that guards required IDs with `empty( $input['order_id'] )` will false-reject a legitimate `"0"` and return a "missing" error for input that's actually present. The agent retries with the same input, gets the same error, and the call is stuck.
|
|
139
|
+
|
|
140
|
+
### Symptoms
|
|
141
|
+
|
|
142
|
+
- Abilities reject specific IDs ending in zero or consisting of zero.
|
|
143
|
+
- Unit tests written with non-zero IDs pass; a regression lands the day an agent tries a real zero-ID.
|
|
144
|
+
- `WP_Error( '<plugin>_missing_<field>' )` fires for input the schema validator would have accepted.
|
|
145
|
+
|
|
146
|
+
### Fix — three explicit checks
|
|
147
|
+
|
|
148
|
+
```php
|
|
149
|
+
if ( ! isset( $input['order_id'] )
|
|
150
|
+
|| ! is_string( $input['order_id'] )
|
|
151
|
+
|| '' === $input['order_id']
|
|
152
|
+
) {
|
|
153
|
+
return new \WP_Error(
|
|
154
|
+
'<plugin>_missing_order_id',
|
|
155
|
+
__( 'An order_id is required to fetch order detail.', '<text-domain>' )
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Three separate checks, each non-redundant:
|
|
161
|
+
|
|
162
|
+
1. `! isset( ... )` — the key is present on the array.
|
|
163
|
+
2. `! is_string( ... )` — type is right. (For integer IDs, use `is_int` + non-negative check instead.) Without this, a caller that passes `123` (integer) where the schema documents a string would fall through to a later step that does `rawurlencode( $input['order_id'] )` and produces a cryptic coercion error rather than a clean missing-field response.
|
|
164
|
+
3. `'' === $input[ ... ]` — non-empty in the strict sense. Rejects empty string only; accepts `"0"` as a legal value.
|
|
165
|
+
|
|
166
|
+
Use the standardized `<plugin>_missing_<field>` error code (see `error-code-vocabulary.md`) for this case.
|
|
167
|
+
|
|
168
|
+
### Add a regression-guard unit test
|
|
169
|
+
|
|
170
|
+
The guard is load-bearing enough that it deserves an explicit test — otherwise someone will simplify it back to `empty()` in a future refactor:
|
|
171
|
+
|
|
172
|
+
```php
|
|
173
|
+
public function test_execute_returns_wp_error_when_id_not_a_string() {
|
|
174
|
+
$result = My_Plugin_Abilities::execute_get_thing( [ 'order_id' => 123 ] );
|
|
175
|
+
$this->assertInstanceOf( \WP_Error::class, $result );
|
|
176
|
+
$this->assertSame( '<plugin>_missing_order_id', $result->get_error_code() );
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The integer `123` is a fine canary — it's non-empty, it `isset`s, but it's not a string. A callback that only uses `isset` + `empty` would false-pass this input and fall through to the URL construction with an integer argument.
|
|
181
|
+
|
|
182
|
+
## 4. Direct vs indirect invocation differ in strictness
|
|
183
|
+
|
|
184
|
+
### Problem
|
|
185
|
+
|
|
186
|
+
Two ways exist to invoke an ability's `execute_callback`:
|
|
187
|
+
|
|
188
|
+
- **Direct.** A caller imports the registrar and calls the static method (`Abilities_Registrar::execute_get_things( $input )`). PHP signature defaults apply; no schema validation runs.
|
|
189
|
+
- **Indirect.** A caller resolves the ability through `wp_get_ability( '<id>' )->execute( $input )`. WordPress runs `WP_Ability::normalize_input()` then `validate_input()` (then `check_permissions()`) before the callback is invoked.
|
|
190
|
+
|
|
191
|
+
The two paths differ in three ways agents trip over:
|
|
192
|
+
|
|
193
|
+
1. **Validation against `input_schema` runs only on the indirect path.** If the ability declares an object-typed schema with properties and the indirect caller passes `null` (or omits the argument entirely, as `$ability->execute()` does), `validate_input()` rejects with an `ability_invalid_input` error before the callback runs. The PHP-level `$input = null` default in the callback signature is dead code on this path.
|
|
194
|
+
|
|
195
|
+
2. **Schema's top-level `default` IS applied — but only on the indirect path.** `normalize_input()` substitutes the schema's top-level `default` when the caller passes `null`. Direct callers don't run through this method; they get whatever PHP default the callback signature declares. Declaring `default => (object) array()` at the schema root makes `$ability->execute()` work without arguments — but the same callback called directly still receives PHP `null`.
|
|
196
|
+
|
|
197
|
+
3. **The callback's first argument arrives only when `input_schema` is non-empty.** WordPress only forwards `$input` to the callback when the schema is declared. Without an input schema, the callback is invoked with zero arguments — PHP-level signature defaults compensate for this if you wrote them; without them, the indirect path produces an `ArgumentCountError`.
|
|
198
|
+
|
|
199
|
+
### Symptoms
|
|
200
|
+
|
|
201
|
+
- An ability looks fine when unit-tested by directly invoking the static method, then fails the moment it's exercised through `wp_get_ability(...)->execute()` from MCP / Command Palette / agent harnesses.
|
|
202
|
+
- "Works in the test, breaks in MCP" — typical pattern when a test calls `Abilities_Registrar::execute_get_things( [] )` (passing an empty array) but the agent invocation goes through the indirect pipeline.
|
|
203
|
+
- A schema with all-optional properties still rejects zero-arg indirect calls because `null` is not an object.
|
|
204
|
+
|
|
205
|
+
### Fix — declare a top-level schema `default` for any zero-arg-allowed ability
|
|
206
|
+
|
|
207
|
+
```php
|
|
208
|
+
'input_schema' => [
|
|
209
|
+
'type' => 'object',
|
|
210
|
+
'default' => (object) array(), // applied by normalize_input() on null inputs
|
|
211
|
+
'properties' => [
|
|
212
|
+
'per_page' => [ 'type' => 'integer', 'default' => 10 ],
|
|
213
|
+
// ...
|
|
214
|
+
],
|
|
215
|
+
],
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`(object) array()` is preferred over `[]` because it json-encodes to `{}` rather than the array literal `[]`, avoiding PHP's array/object ambiguity at the validator boundary.
|
|
219
|
+
|
|
220
|
+
### Distinguish from Gotcha 1
|
|
221
|
+
|
|
222
|
+
Top-level schema `default` is honored by `normalize_input()` and reaches the callback. Property-level `default` (Gotcha 1) is NOT — those values are dropped by the validator, and the callback has to defensively reapply them. Different layers, different fates.
|
|
223
|
+
|
|
224
|
+
If you've declared the top-level `default` in the schema, the PHP-level signature default exists only for direct callers. Don't add a third layer of fallback inside the callback that re-checks `if ( $input === null )` — three compensating defaults stacked on each other diffuses the meaning of "no input."
|
|
225
|
+
|
|
226
|
+
## Putting gotchas 1-3 together
|
|
227
|
+
|
|
228
|
+
A hardened execute callback for a list-style ability with a required ID, schema defaults, and backing pagination drift:
|
|
229
|
+
|
|
230
|
+
```php
|
|
231
|
+
public static function execute_get_thing_details( $input = null ) {
|
|
232
|
+
if ( ! is_array( $input ) ) {
|
|
233
|
+
$input = [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Gotcha 3: required-ID validation (not empty()).
|
|
237
|
+
if ( ! isset( $input['thing_id'] )
|
|
238
|
+
|| ! is_string( $input['thing_id'] )
|
|
239
|
+
|| '' === $input['thing_id']
|
|
240
|
+
) {
|
|
241
|
+
return new \WP_Error(
|
|
242
|
+
'<plugin>_missing_thing_id',
|
|
243
|
+
__( 'A thing_id is required.', '<text-domain>' )
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Gotcha 1: apply defaults the Abilities API doesn't inject.
|
|
248
|
+
if ( ! array_key_exists( 'include_history', $input ) || null === $input['include_history'] ) {
|
|
249
|
+
$input['include_history'] = false;
|
|
250
|
+
}
|
|
251
|
+
$input['include_history'] = (bool) $input['include_history'];
|
|
252
|
+
|
|
253
|
+
// Gotcha 2: translate pagination before delegating (only if backing reads a non-standard key).
|
|
254
|
+
$input = self::translate_pagination_keys( $input );
|
|
255
|
+
|
|
256
|
+
return self::delegate_to_rest_controller(
|
|
257
|
+
'<Backing_Controller_Class>',
|
|
258
|
+
'get_thing',
|
|
259
|
+
'/my-plugin/v1/things/' . rawurlencode( $input['thing_id'] ),
|
|
260
|
+
$input
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Each of the three lines the callback adds on top of the "just delegate" pattern has a paid-for bug behind it. Keep them.
|
|
@@ -25,11 +25,22 @@ add_action( 'wp_abilities_api_categories_init', function() {
|
|
|
25
25
|
// 2. Then register abilities
|
|
26
26
|
add_action( 'wp_abilities_api_init', function() {
|
|
27
27
|
wp_register_ability( 'my-plugin/get-info', [
|
|
28
|
-
'label'
|
|
29
|
-
'description'
|
|
30
|
-
'category'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
28
|
+
'label' => __( 'Get Site Info', 'my-plugin' ),
|
|
29
|
+
'description' => __( 'Returns basic site information.', 'my-plugin' ),
|
|
30
|
+
'category' => 'my-plugin',
|
|
31
|
+
'execute_callback' => 'my_plugin_get_info_callback',
|
|
32
|
+
'permission_callback' => 'my_plugin_get_info_permissions',
|
|
33
|
+
'meta' => [
|
|
34
|
+
'show_in_rest' => true,
|
|
35
|
+
'mcp' => [
|
|
36
|
+
'public' => true, // Expose via the bundled WordPress MCP adapter.
|
|
37
|
+
],
|
|
38
|
+
'annotations' => [
|
|
39
|
+
'readonly' => true,
|
|
40
|
+
'destructive' => false,
|
|
41
|
+
'idempotent' => true,
|
|
42
|
+
],
|
|
43
|
+
],
|
|
33
44
|
] );
|
|
34
45
|
} );
|
|
35
46
|
```
|
|
@@ -41,27 +52,43 @@ add_action( 'wp_abilities_api_init', function() {
|
|
|
41
52
|
|
|
42
53
|
## Key arguments for `wp_register_ability()`
|
|
43
54
|
|
|
44
|
-
| Argument | Description |
|
|
45
|
-
|
|
46
|
-
| `label` | Human-readable name for UI (e.g., command palette) |
|
|
47
|
-
| `description` | What the ability does |
|
|
48
|
-
| `category` | Category ID (must be registered first) |
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `meta.show_in_rest` | Set `true` to expose via REST API |
|
|
54
|
-
| `meta.
|
|
55
|
+
| Argument | Required? | Description |
|
|
56
|
+
|----------|-----------|-------------|
|
|
57
|
+
| `label` | **Required** | Human-readable name for UI (e.g., command palette). |
|
|
58
|
+
| `description` | **Required** | What the ability does. |
|
|
59
|
+
| `category` | **Required** | Category ID (must be registered first via `wp_abilities_api_categories_init`). |
|
|
60
|
+
| `execute_callback` | **Required** | Function that runs when the ability is invoked. Receives mixed input (per `input_schema`), returns mixed result or `WP_Error`. |
|
|
61
|
+
| `permission_callback` | **Required** | Function that checks whether the current user may execute. Receives the same mixed input as `execute_callback`; returns `bool` or `WP_Error`. WP core throws `InvalidArgumentException` if this is missing — there is no implicit default. |
|
|
62
|
+
| `input_schema` | Optional | JSON Schema for expected input (enables validation). Required when the ability accepts input. |
|
|
63
|
+
| `output_schema` | Optional | JSON Schema for returned output (enables validation of the result). |
|
|
64
|
+
| `meta.show_in_rest` | Optional (default `false`) | Set `true` to expose via the `wp-abilities/v1` REST API namespace. |
|
|
65
|
+
| `meta.mcp.public` | Optional (default `false`) | Set `true` to expose the ability as a tool via the bundled WordPress MCP adapter. Independent from `show_in_rest`. |
|
|
66
|
+
| `meta.mcp.type` | Optional (default `'tool'`) | One of `'tool'`, `'resource'`, `'prompt'`. Controls how the bundled MCP adapter projects the ability. Values outside this enum silently coerce to `'tool'`. |
|
|
67
|
+
| `meta.annotations.readonly` | **Strongly recommended** (default `null`) | `true` if the ability does not modify its environment. |
|
|
68
|
+
| `meta.annotations.destructive` | **Strongly recommended** (default `null`) | `true` if the ability may perform destructive updates. `false` for additive-only updates. |
|
|
69
|
+
| `meta.annotations.idempotent` | **Strongly recommended** (default `null`) | `true` if calling the ability repeatedly with the same arguments has no additional effect. |
|
|
70
|
+
|
|
71
|
+
The three annotations under `meta.annotations` are *hints* for tooling and documentation — core does not enforce them at runtime, so a missing or `null` value is silently legal. That permissiveness is exactly why every registration should populate them explicitly: MCP / Command Palette / agent surfaces and review tooling reason about ability safety from these values *without* invoking the callback. A `readonly: null` ability is treated as "behavior unknown," which is a worse signal than either `true` or `false`. Treat the absence of an annotation as a bug, not a default.
|
|
72
|
+
|
|
73
|
+
### `show_in_rest` vs `mcp.public` — they target different surfaces
|
|
74
|
+
|
|
75
|
+
These two meta keys answer different questions and do not imply each other:
|
|
76
|
+
|
|
77
|
+
- `show_in_rest` controls visibility on the WordPress core REST namespace `wp-abilities/v1` (the abilities REST API). Clients that talk to that namespace see the ability iff this is `true`.
|
|
78
|
+
- `mcp.public` is read by the bundled WordPress MCP adapter package. The adapter's default MCP server only surfaces abilities whose `meta.mcp.public` is strictly `true`. Without it, the ability is registered but invisible to MCP clients connecting through that adapter.
|
|
79
|
+
|
|
80
|
+
A plugin can set both, either, or neither. If you want the ability discoverable to agents through MCP, set `mcp.public => true`. If you also want it on the abilities REST namespace (for tooling that talks to `wp-abilities/v1` directly), set `show_in_rest => true`. The two surfaces are independent.
|
|
55
81
|
|
|
56
82
|
## Recommended patterns
|
|
57
83
|
|
|
58
|
-
- Namespace IDs (e.g
|
|
59
|
-
- Treat IDs as stable API; changing
|
|
84
|
+
- Namespace ability IDs as `<plugin-slug>/<verb-noun>` (e.g., `my-plugin/get-info`, `my-plugin/update-thing`). Slash-separated.
|
|
85
|
+
- Treat IDs as stable API; changing an ID is a breaking change for any consumer that holds a reference.
|
|
60
86
|
- Use `input_schema` and `output_schema` for validation and to help AI agents understand usage.
|
|
61
|
-
- Always include a `permission_callback
|
|
87
|
+
- **Always include a `permission_callback`.** It is required on every registration — there is no implicit default.
|
|
88
|
+
- **Always set all three `meta.annotations` keys (`readonly`, `destructive`, `idempotent`) explicitly.** Leaving them at the `null` default broadcasts "behavior unknown" to every consumer that reads this metadata before invoking the ability. The cost of writing them is three lines; the cost of omitting them is opaque safety surface.
|
|
62
89
|
|
|
63
90
|
## References
|
|
64
91
|
|
|
65
92
|
- Abilities API handbook: https://developer.wordpress.org/apis/abilities-api/
|
|
66
93
|
- Dev note: https://make.wordpress.org/core/2025/11/10/abilities-api-in-wordpress-6-9/
|
|
67
|
-
|
|
94
|
+
- WordPress MCP adapter package — source of `meta.mcp.public` / `meta.mcp.type` contract. The adapter ships as a packaged dependency; verify the meta-key names against the package version your plugin pulls in.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Plugin-family patterns
|
|
2
|
+
|
|
3
|
+
This reference covers shared implementation *mechanics* — the call shape your execute callbacks follow when handing work to existing business logic. Two shapes are common enough across real-world WordPress plugins to be worth naming; which one fits is determined by how the backing controller is constructed, not by a choice you make up front. The choice still ripples through your delegate helper, your tests, and your error codes, so it's worth confirming early.
|
|
4
|
+
|
|
5
|
+
Family-specific *registration* conventions — loader path, ability category, MCP exposure defaults, error-code prefix house style — live in separate plugin-family overlays (for example, a WooCommerce-extension overlay), not in this reference. Apply any relevant overlay before scaffolding registration. The patterns below should stay portable: they describe how the execute callback talks to backing code, not what the registration metadata should look like for your plugin family.
|
|
6
|
+
|
|
7
|
+
There is also a third option that sits *outside* this dichotomy. If the operation does not yet have a shared service class — or if you can refactor toward one — extracting a service that the ability, the REST controller, and the UI all consume is the default per `shared-core-service.md`. Delegation through an existing REST controller (either shape below) is a conditional shortcut for low-stakes reads, not the starting point. Confirm the shape is right before you reach for either.
|
|
8
|
+
|
|
9
|
+
## Shape: shared API client
|
|
10
|
+
|
|
11
|
+
Common in plugins that talk to a remote service (Stripe, a first-party SaaS, an upstream API). The plugin bootstraps a single API client, exposes it via a static accessor on a main plugin class, and every REST controller takes that client as a constructor argument.
|
|
12
|
+
|
|
13
|
+
### Detection
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Inspect the backing REST controller's __construct signature.
|
|
17
|
+
grep -n "public function __construct" <path/to/rest-controller.php>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If the constructor takes a required typed argument (e.g. `My_Plugin_API_Client $api_client`), you're in the shared-API-client shape. Confirm the plugin has a central accessor:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
grep -rn "get_api_client\|get_.*_api_client\|get_service" <path/to/plugin/main-class.php>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
You're looking for a `public static function get_<something>()` that returns the shared client.
|
|
27
|
+
|
|
28
|
+
### Minimal skeleton
|
|
29
|
+
|
|
30
|
+
```php
|
|
31
|
+
<?php
|
|
32
|
+
namespace My_Plugin\Abilities;
|
|
33
|
+
|
|
34
|
+
class Abilities_Registrar {
|
|
35
|
+
|
|
36
|
+
const CATEGORY_SLUG = 'my-plugin';
|
|
37
|
+
|
|
38
|
+
public static function init() {
|
|
39
|
+
add_action( 'wp_abilities_api_categories_init', [ __CLASS__, 'register_category' ] );
|
|
40
|
+
add_action( 'wp_abilities_api_init', [ __CLASS__, 'register_abilities' ] );
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public static function register_abilities() {
|
|
44
|
+
wp_register_ability(
|
|
45
|
+
'my-plugin/get-things',
|
|
46
|
+
[
|
|
47
|
+
'label' => __( 'Get things', 'my-plugin' ),
|
|
48
|
+
'description' => __( 'List things with filters.', 'my-plugin' ),
|
|
49
|
+
'category' => self::CATEGORY_SLUG,
|
|
50
|
+
'input_schema' => [ /* ... */ ],
|
|
51
|
+
'execute_callback' => [ __CLASS__, 'execute_get_things' ],
|
|
52
|
+
'permission_callback' => [ __CLASS__, 'current_user_has_capability' ],
|
|
53
|
+
'meta' => [
|
|
54
|
+
'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ],
|
|
55
|
+
'show_in_rest' => true,
|
|
56
|
+
],
|
|
57
|
+
]
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public static function execute_get_things( $input = null ) {
|
|
62
|
+
return self::delegate_to_rest_controller(
|
|
63
|
+
'My_Plugin_REST_Things_Controller',
|
|
64
|
+
'get_things',
|
|
65
|
+
'/my-plugin/v1/things',
|
|
66
|
+
is_array( $input ) ? $input : null
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private static function delegate_to_rest_controller( $controller_class, $method, $route, $input, $http_method = 'GET' ) {
|
|
71
|
+
$fqcn = '\\' . $controller_class;
|
|
72
|
+
if ( ! class_exists( $fqcn ) ) {
|
|
73
|
+
return new \WP_Error( 'my_plugin_not_initialized', __( 'My Plugin is not initialized.', 'my-plugin' ) );
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Shape specifics: fetch the shared API client and null-check.
|
|
77
|
+
$api_client = null;
|
|
78
|
+
if ( class_exists( '\My_Plugin' ) && method_exists( '\My_Plugin', 'get_api_client' ) ) {
|
|
79
|
+
$api_client = \My_Plugin::get_api_client();
|
|
80
|
+
}
|
|
81
|
+
if ( null === $api_client ) {
|
|
82
|
+
return new \WP_Error( 'my_plugin_not_initialized', __( 'My Plugin is not initialized.', 'my-plugin' ) );
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
$request = new \WP_REST_Request( $http_method, $route );
|
|
86
|
+
if ( is_array( $input ) ) {
|
|
87
|
+
foreach ( $input as $param => $value ) {
|
|
88
|
+
$request->set_param( $param, $value );
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$controller = new $fqcn( $api_client );
|
|
93
|
+
$response = $controller->{$method}( $request );
|
|
94
|
+
|
|
95
|
+
if ( is_wp_error( $response ) ) {
|
|
96
|
+
return $response;
|
|
97
|
+
}
|
|
98
|
+
if ( $response instanceof \WP_REST_Response ) {
|
|
99
|
+
$data = $response->get_data();
|
|
100
|
+
return is_array( $data ) ? $data : [];
|
|
101
|
+
}
|
|
102
|
+
return is_array( $response ) ? $response : [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Testing implications
|
|
108
|
+
|
|
109
|
+
- **Bootstrap test doubles need the API client.** Unit tests that instantiate controllers must provide a mocked or stubbed client; otherwise the constructor fails with `ArgumentCountError: Too few arguments`.
|
|
110
|
+
- **The "not initialized" error path is reachable and must be covered.** One unit test should stub the accessor to return null and assert the ability returns `<plugin>_not_initialized`. This is a common failure during partial bootstraps (WP-CLI with restricted loading, test harnesses, admin pages with conditional autoloading).
|
|
111
|
+
- **Integration tests need real or faked HTTP.** The backing controller will hit the upstream unless the API client is mocked, so integration harnesses typically route through a fake transport.
|
|
112
|
+
|
|
113
|
+
Worked example: a plugin that talks to an external SaaS or upstream HTTP service typically follows this pattern. The plugin's main class exposes the API client via a static accessor (e.g. `Plugin_Main::get_api_client()`); REST controllers extend a base class whose constructor takes that client as a typed argument; the abilities registrar pulls the client through the same accessor before constructing controllers.
|
|
114
|
+
|
|
115
|
+
## Shape: zero-arg controllers
|
|
116
|
+
|
|
117
|
+
Common in plugins/packages that delegate primarily to WordPress core mechanisms (custom post types, options, meta) and don't maintain a single shared API client. Controllers instantiate cleanly without a dependency graph.
|
|
118
|
+
|
|
119
|
+
### Detection
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
grep -n "public function __construct" <path/to/rest-controller.php>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
If the constructor takes no required arguments, or takes only simple scalars you can hardcode at the ability call site (e.g. a post-type string), you're in the zero-arg shape.
|
|
126
|
+
|
|
127
|
+
Also grep the plugin's main bootstrap class for the absence of a singleton API accessor — if there isn't one, the zero-arg shape is the honest choice.
|
|
128
|
+
|
|
129
|
+
### Minimal skeleton
|
|
130
|
+
|
|
131
|
+
```php
|
|
132
|
+
<?php
|
|
133
|
+
namespace My_Plugin\Abilities;
|
|
134
|
+
|
|
135
|
+
class Abilities_Registrar {
|
|
136
|
+
|
|
137
|
+
const CATEGORY_SLUG = 'my-plugin';
|
|
138
|
+
|
|
139
|
+
public static function init() {
|
|
140
|
+
add_action( 'wp_abilities_api_categories_init', [ __CLASS__, 'register_category' ] );
|
|
141
|
+
add_action( 'wp_abilities_api_init', [ __CLASS__, 'register_abilities' ] );
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
public static function register_abilities() {
|
|
145
|
+
wp_register_ability(
|
|
146
|
+
'my-plugin/get-things',
|
|
147
|
+
[
|
|
148
|
+
/* ... */
|
|
149
|
+
'execute_callback' => [ __CLASS__, 'execute_get_things' ],
|
|
150
|
+
/* ... */
|
|
151
|
+
]
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public static function execute_get_things( $input = null ) {
|
|
156
|
+
$input = is_array( $input ) ? $input : [];
|
|
157
|
+
$endpoint = new \My_Plugin_Things_Endpoint();
|
|
158
|
+
$request = new \WP_REST_Request( 'GET', '/my-plugin/v1/things' );
|
|
159
|
+
|
|
160
|
+
foreach ( [ 'page', 'per_page', 'status', 'search' ] as $key ) {
|
|
161
|
+
if ( isset( $input[ $key ] ) ) {
|
|
162
|
+
$request->set_param( $key, $input[ $key ] );
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
$response = $endpoint->get_items( $request );
|
|
167
|
+
if ( is_wp_error( $response ) ) {
|
|
168
|
+
return $response;
|
|
169
|
+
}
|
|
170
|
+
return $response instanceof \WP_REST_Response ? $response->get_data() : $response;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
No helper is required — the construction step is a single line and inlining per callback stays readable. If you do extract a helper, it takes a `constructor_args` parameter because the controller class isn't constant across abilities:
|
|
176
|
+
|
|
177
|
+
```php
|
|
178
|
+
/**
|
|
179
|
+
* @param string $controller_class Fully-qualified controller class.
|
|
180
|
+
* @param string $method Method to invoke on the request.
|
|
181
|
+
* @param string $route REST route used when constructing WP_REST_Request.
|
|
182
|
+
* @param array|null $input Ability input (or null).
|
|
183
|
+
* @param string $http_method HTTP method. Defaults to 'GET'.
|
|
184
|
+
* @param array $constructor_args Optional scalar args for the controller's constructor.
|
|
185
|
+
* @return array|\WP_Error
|
|
186
|
+
*/
|
|
187
|
+
private static function delegate_to_rest_controller(
|
|
188
|
+
$controller_class,
|
|
189
|
+
$method,
|
|
190
|
+
$route,
|
|
191
|
+
$input,
|
|
192
|
+
$http_method = 'GET',
|
|
193
|
+
$constructor_args = array()
|
|
194
|
+
) {
|
|
195
|
+
/* ... */
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The phpDoc carries the `array|\WP_Error` return shape; the function signature itself stays untyped on the return so this snippet parses on PHP 7.2+.
|
|
200
|
+
|
|
201
|
+
See `delegate-helper-pattern.md` for the full helper signature and guards.
|
|
202
|
+
|
|
203
|
+
### Testing implications
|
|
204
|
+
|
|
205
|
+
- **No API-client mock needed.** Unit tests instantiate the ability registrar directly and call `execute_*` with input arrays.
|
|
206
|
+
- **Test at the `wp_get_ability()` level when possible.** With zero-arg controllers the backing often exists in WordPress core territory (e.g. custom post types), so integration tests can exercise the full pipeline without a fake transport.
|
|
207
|
+
- **The `<plugin>_not_initialized` failure mode is less common.** It still exists — if a package isn't loaded, `class_exists` on the backing controller still fails — but the surface area is smaller.
|
|
208
|
+
|
|
209
|
+
Worked example: a plugin whose REST controllers wrap a custom post type, taxonomy, option, or meta typically follows this pattern. Each execute callback constructs its controller inline (e.g. `new My_CPT_Endpoint( 'my_cpt' )`), passing whatever scalar (post-type slug, taxonomy name) the controller needs. No shared helper in the registrar; the construction step is small enough to inline.
|
|
210
|
+
|
|
211
|
+
## Hybrid cases
|
|
212
|
+
|
|
213
|
+
A single plugin can legitimately use both patterns across different abilities:
|
|
214
|
+
|
|
215
|
+
- A shared-API-client ability for backing controllers that talk to the upstream service.
|
|
216
|
+
- A zero-arg ability for backing controllers that only touch WP core (e.g. a CPT-based settings endpoint in the same plugin).
|
|
217
|
+
|
|
218
|
+
If this happens, the helper in `delegate-helper-pattern.md` can support both via optional `constructor_args` — but resist the temptation to over-engineer until you have at least two zero-arg abilities that would share the helper.
|
|
219
|
+
|
|
220
|
+
## Anti-pattern — inventing an API client where there isn't one
|
|
221
|
+
|
|
222
|
+
Don't introduce a fake shared API client to fit the shared-client helper shape. If the plugin is genuinely stateless / CPT-driven, inline construction is the honest answer. The helper is scaffolding that pays back when you have 4+ abilities sharing the build-request → instantiate → unwrap flow; before that, inline code is faster to read and review.
|
|
223
|
+
|
|
224
|
+
## Picking quickly
|
|
225
|
+
|
|
226
|
+
| Signal | Likely pattern |
|
|
227
|
+
|---|---|
|
|
228
|
+
| Backing controller's `__construct` takes an API-client-like object | A |
|
|
229
|
+
| Plugin has a `Plugin::get_*_client()` static accessor | A |
|
|
230
|
+
| Plugin talks to an external SaaS / upstream HTTP service | A |
|
|
231
|
+
| Backing controller `__construct` is zero-arg or only takes scalars | B |
|
|
232
|
+
| Backing is a CPT / options / meta wrapper | B |
|
|
233
|
+
| Plugin's REST surface wraps WP-core post types, taxonomies, options, or meta | B |
|