PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / 3.3.0
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar v3.3.0
3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 All 156 releases
notificationx / includes / Abilities / AbilityBase.php

AbilityBase.php in NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar 3.3.0, at includes/Abilities/AbilityBase.php

307 lines 9.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Base class for every NotificationX MCP "ability".
4 *
5 * An ability is a single, self-describing capability (list notifications, read
6 * analytics, toggle a campaign, …) exposed to AI assistants through the MCP
7 * server. The shape here intentionally mirrors the WordPress Abilities API
8 * (`wp_register_ability()`): an id, human labels, JSON-Schema input/output, a
9 * permission callback and an execute callback. Keeping the same contract means
10 * these abilities can be handed to WordPress core's Abilities API (or the Pro
11 * add-on) later with no rewrite — the registrar just registers them elsewhere.
12 *
13 * @package NotificationX\Abilities
14 */
15
16 namespace NotificationX\Abilities;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * Abstract ability. Concrete abilities live in Abilities/Read and Abilities/Manage.
24 */
25 abstract class AbilityBase {
26
27 /**
28 * Fully-qualified ability id, e.g. "notificationx/list-notifications".
29 * The part before the slash is the category; the part after becomes the
30 * MCP tool name (which cannot contain a slash).
31 *
32 * @var string
33 */
34 protected $id = '';
35
36 /**
37 * Short human label shown in tooling.
38 *
39 * @var string
40 */
41 protected $label = '';
42
43 /**
44 * One-line description surfaced to the AI assistant so it knows when to
45 * reach for this tool. Keep it specific and action oriented.
46 *
47 * @var string
48 */
49 protected $description = '';
50
51 /**
52 * WordPress capability the caller must have. Every ability is admin-only
53 * by default; the MCP server additionally refuses any grant whose issuing
54 * user is not an administrator.
55 *
56 * @var string
57 */
58 protected $capability = 'manage_options';
59
60 /**
61 * Whether the ability mutates state. Read-only tokens (and the read-only
62 * OAuth scope) may call read abilities only. Concrete write abilities set
63 * this to true.
64 *
65 * @var bool
66 */
67 protected $is_write = false;
68
69 /**
70 * Whether repeating the call with the same input has the same effect
71 * (used only as an MCP annotation hint for the client).
72 *
73 * @var bool
74 */
75 protected $is_idempotent = true;
76
77 /**
78 * JSON Schema for the ability input (an object describing accepted args).
79 *
80 * @return array
81 */
82 abstract public function input_schema();
83
84 /**
85 * JSON Schema for the ability output.
86 *
87 * @return array
88 */
89 abstract public function output_schema();
90
91 /**
92 * Do the work. Runs only after the permission check has passed and the
93 * input has been validated against input_schema().
94 *
95 * @param array $input Sanitized, schema-validated input.
96 * @return array|\WP_Error
97 */
98 abstract public function execute( $input );
99
100 /**
101 * @return string
102 */
103 public function get_id() {
104 return $this->id;
105 }
106
107 /**
108 * @return string
109 */
110 public function get_label() {
111 return $this->label;
112 }
113
114 /**
115 * @return string
116 */
117 public function get_description() {
118 return $this->description;
119 }
120
121 /**
122 * @return bool
123 */
124 public function is_write() {
125 return (bool) $this->is_write;
126 }
127
128 /**
129 * The MCP tool name — the ability id with its category prefix stripped
130 * ("notificationx/list-notifications" => "list-notifications").
131 *
132 * @return string
133 */
134 public function tool_name() {
135 $pos = strpos( $this->id, '/' );
136 return false === $pos ? $this->id : substr( $this->id, $pos + 1 );
137 }
138
139 /**
140 * MCP tool annotations — behavioural hints for the client.
141 *
142 * @return array
143 */
144 public function annotations() {
145 return array(
146 'title' => $this->label,
147 'readOnlyHint' => ! $this->is_write,
148 'destructiveHint' => $this->is_write && ! $this->is_idempotent,
149 'idempotentHint' => $this->is_idempotent,
150 'openWorldHint' => false,
151 );
152 }
153
154 /**
155 * Whether the current user may run this ability.
156 *
157 * @return bool
158 */
159 public function permission_callback() {
160 return current_user_can( $this->capability );
161 }
162
163 /**
164 * Full run pipeline used by the MCP server: permission check → input
165 * validation → execute, wrapped in action hooks so Pro/3rd-party can
166 * observe. Returns the ability result or a WP_Error.
167 *
168 * @param array $input Raw input arguments.
169 * @return array|\WP_Error
170 */
171 public function run( $input = array() ) {
172 if ( ! $this->permission_callback() ) {
173 return new \WP_Error(
174 'nx_mcp_forbidden',
175 __( 'You are not allowed to run this ability.', 'notificationx' ),
176 array( 'status' => 403 )
177 );
178 }
179
180 $input = is_array( $input ) ? $input : array();
181
182 $validated = $this->validate_input( $input );
183 if ( is_wp_error( $validated ) ) {
184 return $validated;
185 }
186
187 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Prefixed with nx_ per NotificationX convention.
188 do_action( 'nx_before_ability_execute', $this->id, $validated );
189
190 $result = $this->execute( $validated );
191
192 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Prefixed with nx_ per NotificationX convention.
193 do_action( 'nx_after_ability_execute', $this->id, $validated, $result );
194
195 return $result;
196 }
197
198 /**
199 * Minimal JSON-Schema validation: enforce required keys and primitive
200 * types declared in input_schema(). Unknown keys are dropped so an
201 * assistant cannot smuggle extra fields into the execute() payload.
202 *
203 * @param array $input Raw input.
204 * @return array|\WP_Error Cleaned input or error.
205 */
206 protected function validate_input( $input ) {
207 $schema = $this->input_schema();
208 if ( empty( $schema['properties'] ) || ! is_array( $schema['properties'] ) ) {
209 return array();
210 }
211
212 $properties = $schema['properties'];
213 $required = isset( $schema['required'] ) && is_array( $schema['required'] ) ? $schema['required'] : array();
214 $clean = array();
215
216 foreach ( $required as $key ) {
217 if ( ! array_key_exists( $key, $input ) || '' === $input[ $key ] || null === $input[ $key ] ) {
218 return new \WP_Error(
219 'nx_mcp_missing_param',
220 /* translators: %s: parameter name. */
221 sprintf( __( 'Missing required parameter: %s', 'notificationx' ), $key ),
222 array( 'status' => 400 )
223 );
224 }
225 }
226
227 foreach ( $properties as $key => $definition ) {
228 if ( ! array_key_exists( $key, $input ) ) {
229 continue;
230 }
231 $type = isset( $definition['type'] ) ? $definition['type'] : 'string';
232 $value = $input[ $key ];
233
234 switch ( $type ) {
235 case 'integer':
236 $value = is_numeric( $value ) ? (int) $value : 0;
237 break;
238 case 'number':
239 $value = is_numeric( $value ) ? (float) $value : 0;
240 break;
241 case 'boolean':
242 $value = filter_var( $value, FILTER_VALIDATE_BOOLEAN );
243 break;
244 case 'array':
245 $value = is_array( $value ) ? $value : array();
246 break;
247 case 'object':
248 $value = is_array( $value ) ? $value : array();
249 break;
250 default:
251 $value = is_scalar( $value ) ? (string) $value : '';
252 break;
253 }
254
255 // Enforce enum allow-lists when declared.
256 if ( ! empty( $definition['enum'] ) && is_array( $definition['enum'] ) && ! in_array( $value, $definition['enum'], true ) ) {
257 return new \WP_Error(
258 'nx_mcp_invalid_param',
259 /* translators: %s: parameter name. */
260 sprintf( __( 'Invalid value for parameter: %s', 'notificationx' ), $key ),
261 array( 'status' => 400 )
262 );
263 }
264
265 $clean[ $key ] = $value;
266 }
267
268 return $clean;
269 }
270
271 /**
272 * The MCP tool descriptor for tools/list.
273 *
274 * @return array
275 */
276 public function to_tool() {
277 return array(
278 'name' => $this->tool_name(),
279 'description' => $this->description,
280 'inputSchema' => $this->normalize_schema( $this->input_schema() ),
281 'annotations' => $this->annotations(),
282 );
283 }
284
285 /**
286 * MCP clients expect an object schema; an empty "properties" must be an
287 * object ({}) not an array ([]) once JSON encoded. Normalize recursively.
288 *
289 * @param array $schema Schema fragment.
290 * @return array|object
291 */
292 protected function normalize_schema( $schema ) {
293 if ( ! is_array( $schema ) ) {
294 return $schema;
295 }
296 if ( array() === $schema ) {
297 return (object) array();
298 }
299 foreach ( $schema as $key => $value ) {
300 if ( is_array( $value ) ) {
301 $schema[ $key ] = $this->normalize_schema( $value );
302 }
303 }
304 return $schema;
305 }
306 }
307