PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / trunk
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback vtrunk
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / doit / abilities / class-avcf-abilities-metadata.php
atarim-visual-collaboration / doit / abilities Last commit date
class-avcf-abilities-base.php 3 weeks ago class-avcf-abilities-block-navigation.php 3 weeks ago class-avcf-abilities-cache.php 3 weeks ago class-avcf-abilities-content.php 1 week ago class-avcf-abilities-core.php 3 days ago class-avcf-abilities-execute-php.php 2 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 2 weeks ago class-avcf-abilities-media.php 1 week ago class-avcf-abilities-metadata.php 1 week ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 days ago class-avcf-abilities-readonly.php 2 weeks ago class-avcf-abilities-settings.php 3 weeks ago class-avcf-abilities-taxonomies.php 3 weeks ago class-avcf-abilities-templates.php 3 weeks ago class-avcf-abilities-theme-files.php 2 weeks ago class-avcf-abilities-themes.php 3 days ago class-avcf-abilities-users.php 3 weeks ago class-avcf-abilities-wp-cli.php 3 days ago
class-avcf-abilities-metadata.php
1393 lines
1 <?php
2 /**
3 * Custom fields, options, and user meta MCP abilities.
4 *
5 * Registers Atarim/* abilities for working with WordPress's various
6 * metadata stores. Unifies four backends behind a consistent surface:
7 *
8 * 1. Raw post_meta (any plugin / theme using add_post_meta)
9 * 2. ACF (Advanced Custom Fields) — with field-key sentinel pairing
10 * 3. Toolset Types — wpcf- prefix convention
11 * 4. Meta Box — registry-walk detection
12 *
13 * Reads on post fields include a "type" hint telling the caller which
14 * backend the field belongs to. Writes accept the type hint back (or
15 * auto-detect when omitted) and route through the right API so that
16 * framework-specific hooks, formatting, and field-key references stay
17 * intact.
18 *
19 * Pods is intentionally not supported in this round — its custom-table
20 * storage model is substantially more work than meta-backed frameworks
21 * and deserves a focused PR.
22 *
23 * Exposed abilities:
24 * atarim/get-post-field Read post fields with type detection; optional all-fields mode.
25 * atarim/update-post-field Write a post field via the detected (or specified) backend.
26 * atarim/bulk-update-post-field Bulk write across many posts, same value or per-item values.
27 * atarim/get-option Read from wp_options.
28 * atarim/update-option Write to wp_options with critical-option blocklist.
29 * atarim/get-user-meta Read user meta keys.
30 * atarim/update-user-meta Write a single user meta key/value.
31 *
32 * Note: ability names registered here must also be added to the $tools
33 * array in doit/class-avcf-mcp.php::avcf_mcp_setup_server() to be exposed
34 * by the MCP server.
35 *
36 * @package atarim-visual-collaboration
37 */
38
39 if ( ! defined('ABSPATH') ) {
40 exit;
41 }
42
43 class AVCF_Abilities_Metadata extends AVCF_Abilities_Base {
44
45 /**
46 * Options that must NEVER be written via update-option. Each entry
47 * names why and (where applicable) the right ability to use instead.
48 */
49 private $option_blocklist = [
50 'siteurl' => 'Use atarim/update-general-settings (validates URL format).',
51 'home' => 'Use atarim/update-general-settings (validates URL format).',
52 'admin_email' => 'Use atarim/update-general-settings (handles WordPress verification flow).',
53 'active_plugins' => 'Use atarim/activate-plugin and atarim/deactivate-plugin (runs activation hooks).',
54 'active_sitewide_plugins'=> 'Use atarim/activate-plugin and atarim/deactivate-plugin (runs activation hooks).',
55 'template' => 'Use atarim/activate-theme (validates theme exists and runs switch hooks).',
56 'stylesheet' => 'Use atarim/activate-theme (validates theme exists and runs switch hooks).',
57 'current_theme' => 'Use atarim/activate-theme (validates theme exists and runs switch hooks).',
58 'db_version' => 'Internal WordPress version tracker — changing this manually breaks upgrades.',
59 'WPLANG' => 'Use atarim/update-general-settings (validates locale).',
60 'cron' => 'Use wp_schedule_event / wp_unschedule_event APIs instead.',
61 'permalink_structure' => 'Use atarim/update-permalink-settings (flushes rewrite rules).',
62 'default_role' => 'Privilege-escalation vector: the role assigned to new users. Change roles through a deliberate, reviewed workflow, not a generic option write.',
63 'users_can_register' => 'Security-sensitive: open registration combined with default_role is a takeover vector. Change via general settings deliberately.',
64 'mailserver_url' => 'Email-interception vector; not writable through the generic option tool.',
65 'mailserver_login' => 'Email-interception vector; not writable through the generic option tool.',
66 'mailserver_pass' => 'Email-interception vector; not writable through the generic option tool.',
67 'mailserver_port' => 'Email-interception vector; not writable through the generic option tool.',
68 ];
69
70 /**
71 * Reason a raw option write is blocked, or null if allowed. Covers the static
72 * infrastructure/security blocklist above plus the prefix-dependent
73 * "{$prefix}user_roles" role-to-capability map, which cannot be a static key
74 * because the table prefix varies per site. Editing user_roles can grant
75 * administrator capabilities, so it is a privilege-escalation vector.
76 *
77 * @param string $name Option name.
78 * @return string|null
79 */
80 private function avcf_blocked_option_reason( $name ) {
81 global $wpdb;
82 if ( isset( $this->option_blocklist[ $name ] ) ) {
83 return $this->option_blocklist[ $name ];
84 }
85 if ( isset( $wpdb ) && is_object( $wpdb ) && $name === $wpdb->prefix . 'user_roles' ) {
86 return 'This is the role-to-capability map; editing it can grant administrator capabilities. Manage roles through a dedicated, reviewed workflow.';
87 }
88 return null;
89 }
90
91 public function register() {
92
93 // ---- get-post-field ----
94 wp_register_ability( 'atarim/get-post-field', [
95 'label' => 'Get Post Field(s)',
96 'description' => 'Reads one or more custom fields (post meta) from a post, returning each with a "type" hint indicating which backend owns it: "acf" (Advanced Custom Fields), "toolset" (Toolset Types), "meta_box" (Meta Box plugin), or "raw" (generic post_meta or unknown framework). The type hint is what you pass back to update-post-field to route writes correctly. Omit the "fields" parameter to return ALL meta on the post (excluding keys starting with "_" by default; pass include_private: true to include those). All-fields mode caps the response at 100 entries with truncated: true if there are more.',
97 'category' => 'atarim',
98 'input_schema' => [
99 'type' => 'object',
100 'properties' => [
101 'post_id' => [
102 'type' => 'integer',
103 'description' => 'Post ID.',
104 'minimum' => 1,
105 ],
106 'fields' => [
107 'type' => 'array',
108 'description' => 'Specific field/meta keys to read. Omit to return all fields on the post.',
109 'items' => [ 'type' => 'string', 'minLength' => 1 ],
110 'minItems' => 1,
111 ],
112 'include_private' => [
113 'type' => 'boolean',
114 'description' => 'In all-fields mode, include meta keys starting with "_" (WordPress private/internal convention). Defaults to false. Ignored when "fields" is specified — keys explicitly listed are always returned.',
115 'default' => false,
116 ],
117 'format' => [
118 'type' => 'string',
119 'description' => 'For ACF fields only: "raw" returns the stored database value (e.g. attachment ID); "formatted" returns ACF\'s post-processed value (e.g. full image array). Defaults to "raw" so AI sees ground truth.',
120 'enum' => [ 'raw', 'formatted' ],
121 'default' => 'raw',
122 ],
123 ],
124 'required' => [ 'post_id' ],
125 'additionalProperties' => false,
126 ],
127 'output_schema' => [
128 'type' => 'object',
129 'properties' => [
130 'success' => [ 'type' => 'boolean' ],
131 'post_id' => [ 'type' => 'integer' ],
132 'mode' => [ 'type' => 'string' ],
133 'count' => [ 'type' => 'integer' ],
134 'truncated' => [ 'type' => 'boolean' ],
135 'total_keys' => [ 'type' => 'integer' ],
136 'fields' => [
137 'type' => 'array',
138 'items' => [
139 'type' => 'object',
140 'properties' => [
141 'key' => [ 'type' => 'string' ],
142 'value' => [],
143 'type' => [ 'type' => 'string' ],
144 ],
145 ],
146 ],
147 'message' => [ 'type' => 'string' ],
148 ],
149 'required' => [ 'success', 'message' ],
150 ],
151 'execute_callback' => function( $input = [] ) {
152 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
153 if ( $post_id <= 0 ) {
154 return [ 'success' => false, 'message' => 'post_id is required and must be a positive integer.' ];
155 }
156
157 $post = get_post( $post_id );
158 if ( ! $post ) {
159 return [ 'success' => false, 'message' => sprintf( 'Post %d not found.', $post_id ) ];
160 }
161
162 $pt_obj = get_post_type_object( $post->post_type );
163 if ( $pt_obj && ! current_user_can( $pt_obj->cap->read_post, $post_id ) ) {
164 return [ 'success' => false, 'message' => sprintf( 'You do not have permission to read this %s.', $post->post_type ) ];
165 }
166
167 $format = isset( $input['format'] ) && $input['format'] === 'formatted' ? 'formatted' : 'raw';
168
169 if ( isset( $input['fields'] ) && is_array( $input['fields'] ) && ! empty( $input['fields'] ) ) {
170 // Targeted read.
171 $keys = array_values( array_filter( array_map( 'strval', $input['fields'] ) ) );
172 $fields = [];
173 foreach ( $keys as $key ) {
174 $detected_type = $this->avcf_detect_meta_type( $post_id, $key, null );
175 $value = $this->avcf_read_field_value( $post_id, $key, $detected_type, $format );
176 $fields[] = [
177 'key' => $key,
178 'value' => $value,
179 'type' => $detected_type,
180 ];
181 }
182 return [
183 'success' => true,
184 'post_id' => $post_id,
185 'mode' => 'targeted',
186 'count' => count( $fields ),
187 'fields' => $fields,
188 'message' => sprintf( 'Read %d field(s) from post %d.', count( $fields ), $post_id ),
189 ];
190 }
191
192 // All-fields mode.
193 $include_private = ! empty( $input['include_private'] );
194 $all_meta = get_post_meta( $post_id );
195 if ( ! is_array( $all_meta ) ) {
196 $all_meta = [];
197 }
198
199 $all_keys = array_keys( $all_meta );
200 if ( ! $include_private ) {
201 $all_keys = array_values( array_filter( $all_keys, function( $k ) {
202 return strpos( $k, '_' ) !== 0;
203 } ) );
204 }
205
206 $total_keys = count( $all_keys );
207 $cap = 100;
208 $truncated = $total_keys > $cap;
209 $work_keys = $truncated ? array_slice( $all_keys, 0, $cap ) : $all_keys;
210
211 $fields = [];
212 foreach ( $work_keys as $key ) {
213 $detected_type = $this->avcf_detect_meta_type( $post_id, $key, null );
214 $value = $this->avcf_read_field_value( $post_id, $key, $detected_type, $format );
215 $fields[] = [
216 'key' => $key,
217 'value' => $value,
218 'type' => $detected_type,
219 ];
220 }
221
222 $msg = sprintf( 'Read %d of %d field(s) from post %d.', count( $fields ), $total_keys, $post_id );
223 if ( $truncated ) {
224 $msg .= ' Response truncated to 100 fields — pass specific fields in the "fields" parameter to read more.';
225 }
226
227 return [
228 'success' => true,
229 'post_id' => $post_id,
230 'mode' => 'all',
231 'count' => count( $fields ),
232 'truncated' => $truncated,
233 'total_keys' => $total_keys,
234 'fields' => $fields,
235 'message' => $msg,
236 ];
237 },
238 'permission_callback' => function() {
239 return current_user_can( 'edit_posts' );
240 },
241 'meta' => [
242 'mcp' => [ 'public' => true, 'type' => 'tool' ],
243 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ],
244 ],
245 ] );
246
247 // ---- update-post-field ----
248 wp_register_ability( 'atarim/update-post-field', [
249 'label' => 'Update Post Field',
250 'description' => 'Writes a single custom field (post meta) on a post. By default auto-detects which backend owns the field — ACF, Toolset Types, Meta Box, or raw post_meta — and routes the write through that framework\'s API so its hooks, formatting, and field-key references stay intact. Pass the "type" parameter to force a specific backend (acts as an escape hatch): "acf" routes via update_field(), "toolset" via raw post_meta (Toolset stores as post_meta), "meta_box" via rwmb_set_meta() if available, "raw" via update_post_meta() unconditionally. The caller\'s type is trusted without re-verification.',
251 'category' => 'atarim',
252 'input_schema' => [
253 'type' => 'object',
254 'properties' => [
255 'post_id' => [
256 'type' => 'integer',
257 'description' => 'Post ID.',
258 'minimum' => 1,
259 ],
260 'field' => [
261 'type' => 'string',
262 'description' => 'Field name (for ACF, the field name or field key like field_5f8a1b2c3d4e5; for others, the meta key).',
263 'minLength' => 1,
264 ],
265 'value' => [
266 'description' => 'New value. Type depends on the field — strings, numbers, booleans, arrays, and objects are all accepted.',
267 ],
268 'type' => [
269 'type' => 'string',
270 'description' => 'Force the write backend. Omit to auto-detect. Caller\'s value is trusted.',
271 'enum' => [ 'acf', 'toolset', 'meta_box', 'raw' ],
272 ],
273 ],
274 'required' => [ 'post_id', 'field', 'value' ],
275 'additionalProperties' => false,
276 ],
277 'output_schema' => [
278 'type' => 'object',
279 'properties' => [
280 'success' => [ 'type' => 'boolean' ],
281 'post_id' => [ 'type' => 'integer' ],
282 'field' => [ 'type' => 'string' ],
283 'type' => [ 'type' => 'string' ],
284 'type_source' => [ 'type' => 'string' ],
285 'new_value' => [],
286 'message' => [ 'type' => 'string' ],
287 ],
288 'required' => [ 'success', 'message' ],
289 ],
290 'execute_callback' => function( $input = [] ) {
291 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
292 $field = isset( $input['field'] ) ? (string) $input['field'] : '';
293 if ( $post_id <= 0 || $field === '' ) {
294 return [ 'success' => false, 'message' => 'post_id and field are required.' ];
295 }
296 if ( ! array_key_exists( 'value', $input ) ) {
297 return [ 'success' => false, 'message' => 'value is required.' ];
298 }
299
300 $post = get_post( $post_id );
301 if ( ! $post ) {
302 return [ 'success' => false, 'message' => sprintf( 'Post %d not found.', $post_id ) ];
303 }
304
305 $pt_obj = get_post_type_object( $post->post_type );
306 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $post_id ) ) {
307 return [ 'success' => false, 'message' => sprintf( 'You do not have permission to edit this %s.', $post->post_type ) ];
308 }
309
310 $value = $input['value'];
311
312 // Security guard: refuse traversal/absolute values on path-bearing
313 // internal meta (e.g. _wp_attached_file) that downstream abilities
314 // resolve to a filesystem path for deletion/overwrite.
315 $unsafe_meta = $this->avcf_reject_unsafe_meta_write( $field, $value );
316 if ( null !== $unsafe_meta ) {
317 return [
318 'success' => false,
319 'post_id' => $post_id,
320 'field' => $field,
321 'message' => $unsafe_meta,
322 ];
323 }
324
325 // Guard: _elementor_data holds a JSON STRING (an array of elements).
326 // A malformed or non-JSON write silently corrupts the whole page
327 // (Elementor fails to parse it), so validate before writing. For
328 // structured edits prefer elementor-edit-element / -apply-operations,
329 // which patch the tree without hand-writing raw JSON.
330 if ( '_elementor_data' === $field ) {
331 if ( ! is_string( $value ) ) {
332 return [
333 'success' => false,
334 'post_id' => $post_id,
335 'field' => $field,
336 'message' => '_elementor_data must be written as a JSON string, not a structured/array value. Pass the JSON as a string, or use elementor-edit-element / elementor-apply-operations for targeted structured edits.',
337 ];
338 }
339 $decoded = json_decode( $value, true );
340 if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) {
341 return [
342 'success' => false,
343 'post_id' => $post_id,
344 'field' => $field,
345 'message' => sprintf( 'Refused: the value for _elementor_data is not valid JSON (%s). Writing malformed JSON would corrupt the Elementor page. Fix the JSON, or use elementor-edit-element / elementor-apply-operations for targeted edits.', json_last_error_msg() ),
346 ];
347 }
348 if ( ! is_array( $decoded ) ) {
349 return [
350 'success' => false,
351 'post_id' => $post_id,
352 'field' => $field,
353 'message' => 'Refused: _elementor_data must be a JSON array of Elementor elements. The provided JSON does not decode to an array.',
354 ];
355 }
356 }
357
358 // Determine backend: caller-provided type, or auto-detect.
359 $type_source = 'auto';
360 if ( isset( $input['type'] ) && in_array( $input['type'], [ 'acf', 'toolset', 'meta_box', 'raw' ], true ) ) {
361 $type = (string) $input['type'];
362 $type_source = 'caller';
363 } else {
364 $type = $this->avcf_detect_meta_type( $post_id, $field, $value );
365 }
366
367 // Capture the prior stored value so we can report whether the write
368 // actually changed anything (a "success" that is really a no-op).
369 $before_value = $this->avcf_read_field_value( $post_id, $field, $type, 'raw' );
370
371 $write_result = $this->avcf_write_field_value( $post_id, $field, $value, $type );
372 if ( $write_result['error'] !== null ) {
373 return [
374 'success' => false,
375 'post_id' => $post_id,
376 'field' => $field,
377 'type' => $type,
378 'type_source' => $type_source,
379 'message' => 'Write failed: ' . $write_result['error'],
380 ];
381 }
382
383 // Read back to confirm — uses raw format so AI sees ground truth.
384 $confirmed_value = $this->avcf_read_field_value( $post_id, $field, $type, 'raw' );
385
386 // Write receipt: compare requested vs stored vs prior so a silent
387 // no-op or a coerced value is visible instead of a bare success.
388 $req_json = wp_json_encode( $value );
389 $stored_json = wp_json_encode( $confirmed_value );
390 $before_json = wp_json_encode( $before_value );
391 $changed = ( $stored_json !== $before_json );
392 if ( $stored_json === $req_json ) {
393 $status = 'applied';
394 } elseif ( $stored_json === $before_json ) {
395 $status = 'ignored'; // stored value did not change — the write had no effect
396 } else {
397 $status = 'coerced'; // backend stored a transformed value (see new_value)
398 }
399
400 $message = sprintf( 'Field "%s" updated on post %d via %s backend (%s).', $field, $post_id, $type, $type_source === 'caller' ? 'caller-specified' : 'auto-detected' );
401 if ( 'ignored' === $status ) {
402 $message = sprintf( 'WARNING: field "%s" on post %d was NOT changed — the stored value is unchanged after the write (possible silent no-op). Backend: %s (%s).', $field, $post_id, $type, $type_source === 'caller' ? 'caller-specified' : 'auto-detected' );
403 } elseif ( 'coerced' === $status ) {
404 $message = sprintf( 'NOTE: field "%s" on post %d was stored with a transformed value (see new_value), which differs from the value sent. Backend: %s (%s).', $field, $post_id, $type, $type_source === 'caller' ? 'caller-specified' : 'auto-detected' );
405 }
406
407 return [
408 'success' => true,
409 'post_id' => $post_id,
410 'field' => $field,
411 'type' => $type,
412 'type_source' => $type_source,
413 'new_value' => $confirmed_value,
414 'changed' => $changed,
415 'status' => $status,
416 'message' => $message,
417 ];
418 },
419 'permission_callback' => function() {
420 return current_user_can( 'edit_posts' );
421 },
422 'meta' => [
423 'mcp' => [ 'public' => true, 'type' => 'tool' ],
424 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => false ],
425 ],
426 ] );
427
428 // ---- bulk-update-post-field ----
429 wp_register_ability( 'atarim/bulk-update-post-field', [
430 'label' => 'Bulk Update Post Field',
431 'description' => 'Writes a single field across many posts. Two modes via input variance: (1) "post_ids" + "value" applies the SAME value to every listed post; (2) "items" with [{post_id, value}, ...] applies per-post values. Pass exactly one of post_ids or items. Optional "type" parameter applies the same backend to every write (acf/toolset/meta_box/raw); omit to auto-detect per post. Per-id success tracking — partial failures surface in the results array with their own messages. Max 500 items per call.',
432 'category' => 'atarim',
433 'input_schema' => [
434 'type' => 'object',
435 'properties' => [
436 'field' => [
437 'type' => 'string',
438 'description' => 'Field name to update on every targeted post.',
439 'minLength' => 1,
440 ],
441 'value' => [
442 'description' => 'Single value for same-value-to-many mode. Required when post_ids is used; ignored when items is used.',
443 ],
444 'post_ids' => [
445 'type' => 'array',
446 'description' => 'Same-value mode: post IDs to update with the single "value". Mutually exclusive with items.',
447 'items' => [ 'type' => 'integer', 'minimum' => 1 ],
448 'minItems' => 1,
449 'maxItems' => 500,
450 ],
451 'items' => [
452 'type' => 'array',
453 'description' => 'Per-item mode: array of {post_id, value} objects. Mutually exclusive with post_ids.',
454 'items' => [
455 'type' => 'object',
456 'properties' => [
457 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
458 'value' => [],
459 ],
460 'required' => [ 'post_id', 'value' ],
461 'additionalProperties' => false,
462 ],
463 'minItems' => 1,
464 'maxItems' => 500,
465 ],
466 'type' => [
467 'type' => 'string',
468 'description' => 'Force the write backend for ALL items. Omit to auto-detect per post.',
469 'enum' => [ 'acf', 'toolset', 'meta_box', 'raw' ],
470 ],
471 ],
472 'required' => [ 'field' ],
473 'additionalProperties' => false,
474 ],
475 'output_schema' => [
476 'type' => 'object',
477 'properties' => [
478 'success' => [ 'type' => 'boolean' ],
479 'mode' => [ 'type' => 'string' ],
480 'attempted' => [ 'type' => 'integer' ],
481 'updated' => [ 'type' => 'integer' ],
482 'failed' => [ 'type' => 'integer' ],
483 'results' => [
484 'type' => 'array',
485 'items' => [
486 'type' => 'object',
487 'properties' => [
488 'post_id' => [ 'type' => 'integer' ],
489 'success' => [ 'type' => 'boolean' ],
490 'type' => [ 'type' => 'string' ],
491 'message' => [ 'type' => 'string' ],
492 ],
493 ],
494 ],
495 'message' => [ 'type' => 'string' ],
496 ],
497 'required' => [ 'success', 'attempted', 'updated', 'failed', 'results', 'message' ],
498 ],
499 'execute_callback' => function( $input = [] ) {
500 $field = isset( $input['field'] ) ? (string) $input['field'] : '';
501 if ( $field === '' ) {
502 return [
503 'success' => false, 'mode' => 'unknown',
504 'attempted' => 0, 'updated' => 0, 'failed' => 0,
505 'results' => [], 'message' => 'field is required.',
506 ];
507 }
508
509 $has_ids = isset( $input['post_ids'] ) && is_array( $input['post_ids'] );
510 $has_items = isset( $input['items'] ) && is_array( $input['items'] );
511
512 if ( $has_ids && $has_items ) {
513 return [
514 'success' => false, 'mode' => 'unknown',
515 'attempted' => 0, 'updated' => 0, 'failed' => 0,
516 'results' => [], 'message' => 'Pass either post_ids or items, not both.',
517 ];
518 }
519 if ( ! $has_ids && ! $has_items ) {
520 return [
521 'success' => false, 'mode' => 'unknown',
522 'attempted' => 0, 'updated' => 0, 'failed' => 0,
523 'results' => [], 'message' => 'Either post_ids or items is required.',
524 ];
525 }
526
527 // Build the working list as [post_id => value, ...]
528 $work = [];
529 $mode = 'unknown';
530 if ( $has_ids ) {
531 if ( ! array_key_exists( 'value', $input ) ) {
532 return [
533 'success' => false, 'mode' => 'same_value',
534 'attempted' => 0, 'updated' => 0, 'failed' => 0,
535 'results' => [], 'message' => 'value is required in same-value-to-many mode.',
536 ];
537 }
538 $mode = 'same_value';
539 $value = $input['value'];
540 foreach ( array_unique( array_map( 'intval', $input['post_ids'] ) ) as $pid ) {
541 if ( $pid > 0 ) {
542 $work[ $pid ] = $value;
543 }
544 }
545 } else {
546 $mode = 'per_item';
547 foreach ( $input['items'] as $row ) {
548 if ( ! is_array( $row ) || ! isset( $row['post_id'] ) || ! array_key_exists( 'value', $row ) ) {
549 continue;
550 }
551 $pid = (int) $row['post_id'];
552 if ( $pid > 0 ) {
553 $work[ $pid ] = $row['value'];
554 }
555 }
556 }
557
558 if ( empty( $work ) ) {
559 return [
560 'success' => false, 'mode' => $mode,
561 'attempted' => 0, 'updated' => 0, 'failed' => 0,
562 'results' => [], 'message' => 'No valid post_id values to process.',
563 ];
564 }
565
566 $forced_type = isset( $input['type'] ) && in_array( $input['type'], [ 'acf', 'toolset', 'meta_box', 'raw' ], true )
567 ? (string) $input['type']
568 : null;
569
570 $results = [];
571 $updated = 0;
572 $failed = 0;
573
574 foreach ( $work as $pid => $val ) {
575 $post = get_post( $pid );
576 if ( ! $post ) {
577 $results[] = [ 'post_id' => $pid, 'success' => false, 'type' => 'unknown', 'message' => 'Post not found.' ];
578 $failed++;
579 continue;
580 }
581 $pt_obj = get_post_type_object( $post->post_type );
582 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $pid ) ) {
583 $results[] = [ 'post_id' => $pid, 'success' => false, 'type' => 'unknown', 'message' => 'Permission denied.' ];
584 $failed++;
585 continue;
586 }
587
588 $unsafe_meta = $this->avcf_reject_unsafe_meta_write( $field, $val );
589 if ( null !== $unsafe_meta ) {
590 $results[] = [ 'post_id' => $pid, 'success' => false, 'type' => 'unknown', 'message' => $unsafe_meta ];
591 $failed++;
592 continue;
593 }
594
595 $type = $forced_type !== null ? $forced_type : $this->avcf_detect_meta_type( $pid, $field, $val );
596 $write = $this->avcf_write_field_value( $pid, $field, $val, $type );
597 if ( $write['error'] !== null ) {
598 $results[] = [ 'post_id' => $pid, 'success' => false, 'type' => $type, 'message' => $write['error'] ];
599 $failed++;
600 continue;
601 }
602 $results[] = [ 'post_id' => $pid, 'success' => true, 'type' => $type, 'message' => 'OK.' ];
603 $updated++;
604 }
605
606 $attempted = count( $work );
607
608 return [
609 'success' => ( $failed === 0 ),
610 'mode' => $mode,
611 'attempted' => $attempted,
612 'updated' => $updated,
613 'failed' => $failed,
614 'results' => $results,
615 'message' => sprintf( '%s mode: %d of %d updated, %d failed.', $mode === 'same_value' ? 'Same-value' : 'Per-item', $updated, $attempted, $failed ),
616 ];
617 },
618 'permission_callback' => function() {
619 return current_user_can( 'edit_posts' );
620 },
621 'meta' => [
622 'mcp' => [ 'public' => true, 'type' => 'tool' ],
623 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ],
624 ],
625 ] );
626
627 // ---- get-option-field ----
628 wp_register_ability( 'atarim/get-option-field', [
629 'label' => 'Get Option Field',
630 'description' => 'Reads one or more framework field values stored on an options/settings page (NOT on a post). This is the options-page counterpart to get-post-field. Currently routes to ACF, whose option values live in wp_options under the special "option" store rather than as post meta. Pass option_ref to target a specific ACF options page that uses a custom post_id; omit it for the default "option" store. Each result carries a "type" hint (currently always "acf"). For a raw wp_options key (not a framework field) use get-option instead.',
631 'category' => 'atarim',
632 'input_schema' => [
633 'type' => 'object',
634 'properties' => [
635 'fields' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ], 'minItems' => 1, 'description' => 'Field name(s) to read from the options page.' ],
636 'option_ref' => [ 'type' => 'string', 'description' => 'ACF options target. Default "option". Use a custom value only if the options page was registered with a custom post_id.', 'default' => 'option' ],
637 'format' => [ 'type' => 'string', 'enum' => [ 'raw', 'formatted' ], 'default' => 'raw', 'description' => 'raw returns the stored value; formatted applies ACF formatting.' ],
638 ],
639 'required' => [ 'fields' ],
640 'additionalProperties' => false,
641 ],
642 'output_schema' => [
643 'type' => 'object',
644 'properties' => [
645 'success' => [ 'type' => 'boolean' ],
646 'fields' => [ 'type' => 'array' ],
647 'message' => [ 'type' => 'string' ],
648 ],
649 'required' => [ 'success', 'message' ],
650 ],
651 'execute_callback' => function( $input = [] ) {
652 if ( ! function_exists( 'get_field' ) ) {
653 return [ 'success' => false, 'message' => 'No options-field backend available (ACF is not active).' ];
654 }
655 $ref = isset( $input['option_ref'] ) && $input['option_ref'] !== '' ? (string) $input['option_ref'] : 'option';
656 $format = isset( $input['format'] ) ? (string) $input['format'] : 'raw';
657 $fields = isset( $input['fields'] ) && is_array( $input['fields'] ) ? $input['fields'] : [];
658 if ( empty( $fields ) ) {
659 return [ 'success' => false, 'message' => 'fields must be a non-empty array.' ];
660 }
661 $out = [];
662 foreach ( $fields as $key ) {
663 $out[] = [ 'key' => (string) $key, 'value' => get_field( (string) $key, $ref, $format === 'formatted' ), 'type' => 'acf' ];
664 }
665 return [ 'success' => true, 'option_ref' => $ref, 'fields' => $out, 'message' => 'OK.' ];
666 },
667 'permission_callback' => function() {
668 return current_user_can( 'manage_options' );
669 },
670 'meta' => [
671 'mcp' => [ 'public' => true, 'type' => 'tool' ],
672 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ],
673 ],
674 ] );
675
676 // ---- update-option-field ----
677 wp_register_ability( 'atarim/update-option-field', [
678 'label' => 'Update Option Field',
679 'description' => 'Writes a single framework field value on an options/settings page (NOT a post) — the options-page counterpart to update-post-field. Routes through the framework\'s API so hooks and formatting stay intact. Currently supports type "acf" (writes via update_field() against the ACF "option" store); Meta Box settings pages, Pods, and ACPT option pages are planned and will report a clear unsupported message until wired. Pass option_ref for an ACF options page registered with a custom post_id; omit for the default "option" store. To set a raw wp_options key use update-option instead.',
680 'category' => 'atarim',
681 'input_schema' => [
682 'type' => 'object',
683 'properties' => [
684 'field' => [ 'type' => 'string', 'description' => 'Field name to write.' ],
685 'value' => [ 'description' => 'Value to store (any JSON type; ACF handles serialization).' ],
686 'option_ref' => [ 'type' => 'string', 'description' => 'ACF options target. Default "option".', 'default' => 'option' ],
687 'type' => [ 'type' => 'string', 'enum' => [ 'acf' ], 'default' => 'acf', 'description' => 'Backend to route through. Only "acf" is wired this round.' ],
688 ],
689 'required' => [ 'field', 'value' ],
690 'additionalProperties' => false,
691 ],
692 'output_schema' => [
693 'type' => 'object',
694 'properties' => [
695 'success' => [ 'type' => 'boolean' ],
696 'field' => [ 'type' => 'string' ],
697 'new_value' => [ 'description' => 'Value read back after the write.' ],
698 'message' => [ 'type' => 'string' ],
699 ],
700 'required' => [ 'success', 'message' ],
701 ],
702 'execute_callback' => function( $input = [] ) {
703 $field = isset( $input['field'] ) ? (string) $input['field'] : '';
704 if ( $field === '' || ! array_key_exists( 'value', $input ) ) {
705 return [ 'success' => false, 'message' => 'field and value are required.' ];
706 }
707 $ref = isset( $input['option_ref'] ) && $input['option_ref'] !== '' ? (string) $input['option_ref'] : 'option';
708 $type = isset( $input['type'] ) ? (string) $input['type'] : 'acf';
709 $res = $this->avcf_write_option_field_value( $field, $input['value'], $ref, $type );
710 if ( $res['error'] !== null ) {
711 return [ 'success' => false, 'field' => $field, 'message' => 'Write failed: ' . $res['error'] ];
712 }
713 $confirmed = function_exists( 'get_field' ) ? get_field( $field, $ref, false ) : null;
714 return [ 'success' => true, 'field' => $field, 'option_ref' => $ref, 'new_value' => $confirmed, 'message' => sprintf( 'Field "%s" updated on options target "%s" via %s.', $field, $ref, $type ) ];
715 },
716 'permission_callback' => function() {
717 return current_user_can( 'manage_options' );
718 },
719 'meta' => [
720 'mcp' => [ 'public' => true, 'type' => 'tool' ],
721 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ],
722 ],
723 ] );
724
725 // ---- bulk-update-option-field ----
726 wp_register_ability( 'atarim/bulk-update-option-field', [
727 'label' => 'Bulk Update Option Field',
728 'description' => 'Writes several framework fields on a single options/settings page in one call. items is [{field, value}, ...]; option_ref and type are shared across all items (default "option" / "acf"). Per-item success is tracked — partial failures surface in results. Max 200 items.',
729 'category' => 'atarim',
730 'input_schema' => [
731 'type' => 'object',
732 'properties' => [
733 'items' => [ 'type' => 'array', 'maxItems' => 200, 'items' => [ 'type' => 'object', 'properties' => [ 'field' => [ 'type' => 'string' ], 'value' => [] ], 'required' => [ 'field', 'value' ], 'additionalProperties' => false ] ],
734 'option_ref' => [ 'type' => 'string', 'default' => 'option' ],
735 'type' => [ 'type' => 'string', 'enum' => [ 'acf' ], 'default' => 'acf' ],
736 ],
737 'required' => [ 'items' ],
738 'additionalProperties' => false,
739 ],
740 'output_schema' => [
741 'type' => 'object',
742 'properties' => [
743 'success' => [ 'type' => 'boolean' ],
744 'results' => [ 'type' => 'array' ],
745 'message' => [ 'type' => 'string' ],
746 ],
747 'required' => [ 'success', 'message' ],
748 ],
749 'execute_callback' => function( $input = [] ) {
750 $items = isset( $input['items'] ) && is_array( $input['items'] ) ? $input['items'] : [];
751 if ( empty( $items ) ) {
752 return [ 'success' => false, 'message' => 'items must be a non-empty array.' ];
753 }
754 $ref = isset( $input['option_ref'] ) && $input['option_ref'] !== '' ? (string) $input['option_ref'] : 'option';
755 $type = isset( $input['type'] ) ? (string) $input['type'] : 'acf';
756 $results = [];
757 $ok = 0;
758 foreach ( $items as $item ) {
759 $field = isset( $item['field'] ) ? (string) $item['field'] : '';
760 if ( $field === '' || ! array_key_exists( 'value', $item ) ) {
761 $results[] = [ 'field' => $field, 'success' => false, 'message' => 'field and value required.' ];
762 continue;
763 }
764 $res = $this->avcf_write_option_field_value( $field, $item['value'], $ref, $type );
765 if ( $res['error'] !== null ) {
766 $results[] = [ 'field' => $field, 'success' => false, 'message' => $res['error'] ];
767 } else {
768 $ok++;
769 $results[] = [ 'field' => $field, 'success' => true, 'message' => 'Updated.' ];
770 }
771 }
772 return [ 'success' => true, 'option_ref' => $ref, 'results' => $results, 'message' => sprintf( '%d of %d field(s) updated.', $ok, count( $items ) ) ];
773 },
774 'permission_callback' => function() {
775 return current_user_can( 'manage_options' );
776 },
777 'meta' => [
778 'mcp' => [ 'public' => true, 'type' => 'tool' ],
779 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ],
780 ],
781 ] );
782
783 // ---- get-option ----
784 wp_register_ability( 'atarim/get-option', [
785 'label' => 'Get Option',
786 'description' => 'Reads a value from the WordPress wp_options table by name. Many WordPress settings (site title, active plugins, theme settings, plugin configurations) live in wp_options. Use the dedicated settings abilities (atarim/get-general-settings, atarim/get-reading-settings, etc.) for WP-native option groups; use this for plugin/theme option keys that don\'t have dedicated abilities. Returns the value as stored, including unserialized arrays.',
787 'category' => 'atarim',
788 'input_schema' => [
789 'type' => 'object',
790 'properties' => [
791 'option_name' => [
792 'type' => 'string',
793 'description' => 'Option name as stored in wp_options.',
794 'minLength' => 1,
795 ],
796 'default' => [
797 'description' => 'Value to return if the option does not exist. Defaults to false (WordPress\'s own default).',
798 ],
799 ],
800 'required' => [ 'option_name' ],
801 'additionalProperties' => false,
802 ],
803 'output_schema' => [
804 'type' => 'object',
805 'properties' => [
806 'success' => [ 'type' => 'boolean' ],
807 'option_name' => [ 'type' => 'string' ],
808 'value' => [],
809 'exists' => [ 'type' => 'boolean' ],
810 'message' => [ 'type' => 'string' ],
811 ],
812 'required' => [ 'success', 'message' ],
813 ],
814 'execute_callback' => function( $input = [] ) {
815 $name = isset( $input['option_name'] ) ? (string) $input['option_name'] : '';
816 if ( $name === '' ) {
817 return [ 'success' => false, 'message' => 'option_name is required.' ];
818 }
819
820 // Use a sentinel to distinguish "doesn't exist" from "exists with falsy value".
821 $sentinel = '__avcf_not_found_' . uniqid();
822 $value = get_option( $name, $sentinel );
823 $exists = ( $value !== $sentinel );
824
825 if ( ! $exists ) {
826 $default = array_key_exists( 'default', $input ) ? $input['default'] : false;
827 return [
828 'success' => true,
829 'option_name' => $name,
830 'value' => $default,
831 'exists' => false,
832 'message' => sprintf( 'Option "%s" does not exist; returning provided default.', $name ),
833 ];
834 }
835
836 return [
837 'success' => true,
838 'option_name' => $name,
839 'value' => $value,
840 'exists' => true,
841 'message' => sprintf( 'Option "%s" read successfully.', $name ),
842 ];
843 },
844 'permission_callback' => function() {
845 return current_user_can( 'manage_options' );
846 },
847 'meta' => [
848 'mcp' => [ 'public' => true, 'type' => 'tool' ],
849 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ],
850 ],
851 ] );
852
853 // ---- update-option ----
854 wp_register_ability( 'atarim/update-option', [
855 'label' => 'Update Option',
856 'description' => 'Writes a value to the WordPress wp_options table. Refuses to write to critical infrastructure options (siteurl, home, active_plugins, template, stylesheet, db_version, admin_email, cron, permalink_structure, etc.) — those have dedicated abilities that handle the proper workflows (validation, activation hooks, rewrite-rules flush, verification flows). Use this for plugin/theme option keys that don\'t have dedicated abilities. Arrays and objects are serialized automatically by WordPress.',
857 'category' => 'atarim',
858 'input_schema' => [
859 'type' => 'object',
860 'properties' => [
861 'option_name' => [
862 'type' => 'string',
863 'description' => 'Option name as stored in wp_options.',
864 'minLength' => 1,
865 ],
866 'value' => [
867 'description' => 'Value to store. Arrays and objects are automatically serialized.',
868 ],
869 'autoload' => [
870 'type' => 'boolean',
871 'description' => 'Whether to autoload this option on every page load. Defaults to true. Set false for large/rarely-read options to reduce per-request overhead.',
872 'default' => true,
873 ],
874 ],
875 'required' => [ 'option_name', 'value' ],
876 'additionalProperties' => false,
877 ],
878 'output_schema' => [
879 'type' => 'object',
880 'properties' => [
881 'success' => [ 'type' => 'boolean' ],
882 'option_name' => [ 'type' => 'string' ],
883 'message' => [ 'type' => 'string' ],
884 ],
885 'required' => [ 'success', 'message' ],
886 ],
887 'execute_callback' => function( $input = [] ) {
888 $name = isset( $input['option_name'] ) ? (string) $input['option_name'] : '';
889 if ( $name === '' ) {
890 return [ 'success' => false, 'message' => 'option_name is required.' ];
891 }
892 if ( ! array_key_exists( 'value', $input ) ) {
893 return [ 'success' => false, 'option_name' => $name, 'message' => 'value is required.' ];
894 }
895
896 $blocked_reason = $this->avcf_blocked_option_reason( $name );
897 if ( null !== $blocked_reason ) {
898 return [
899 'success' => false,
900 'option_name' => $name,
901 'message' => sprintf( 'Option "%s" is blocked for direct write. %s', $name, $blocked_reason ),
902 ];
903 }
904
905 $autoload = ! isset( $input['autoload'] ) ? true : (bool) $input['autoload'];
906 $result = update_option( $name, $input['value'], $autoload );
907
908 // update_option returns false when the value didn't change OR when it failed. Distinguish:
909 if ( ! $result ) {
910 $current = get_option( $name );
911 if ( $current === $input['value'] ) {
912 return [
913 'success' => true,
914 'option_name' => $name,
915 'message' => sprintf( 'Option "%s" already had this value; no change written.', $name ),
916 ];
917 }
918 return [
919 'success' => false,
920 'option_name' => $name,
921 'message' => sprintf( 'Could not write option "%s" (WordPress reported failure).', $name ),
922 ];
923 }
924
925 return [
926 'success' => true,
927 'option_name' => $name,
928 'message' => sprintf( 'Option "%s" updated successfully.', $name ),
929 ];
930 },
931 'permission_callback' => function() {
932 return current_user_can( 'manage_options' );
933 },
934 'meta' => [
935 'mcp' => [ 'public' => true, 'type' => 'tool' ],
936 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ],
937 ],
938 ] );
939
940 // ---- get-user-meta ----
941 wp_register_ability( 'atarim/get-user-meta', [
942 'label' => 'Get User Meta',
943 'description' => 'Reads user meta keys for a user. Pass "keys" to read specific ones; omit to read ALL non-private user meta (keys starting with "_" excluded by default; pass include_private: true to include those). All-keys mode is capped at 100 keys with truncated: true if there are more. User meta currently does NOT include framework type detection — all entries return type: "raw". User-level ACF and similar frameworks aren\'t supported in this round.',
944 'category' => 'atarim',
945 'input_schema' => [
946 'type' => 'object',
947 'properties' => [
948 'user_id' => [
949 'type' => 'integer',
950 'description' => 'User ID.',
951 'minimum' => 1,
952 ],
953 'keys' => [
954 'type' => 'array',
955 'description' => 'Specific meta keys to read. Omit to return all.',
956 'items' => [ 'type' => 'string', 'minLength' => 1 ],
957 'minItems' => 1,
958 ],
959 'include_private' => [
960 'type' => 'boolean',
961 'description' => 'In all-keys mode, include keys starting with "_". Defaults to false.',
962 'default' => false,
963 ],
964 ],
965 'required' => [ 'user_id' ],
966 'additionalProperties' => false,
967 ],
968 'output_schema' => [
969 'type' => 'object',
970 'properties' => [
971 'success' => [ 'type' => 'boolean' ],
972 'user_id' => [ 'type' => 'integer' ],
973 'mode' => [ 'type' => 'string' ],
974 'count' => [ 'type' => 'integer' ],
975 'truncated' => [ 'type' => 'boolean' ],
976 'total_keys' => [ 'type' => 'integer' ],
977 'fields' => [
978 'type' => 'array',
979 'items' => [
980 'type' => 'object',
981 'properties' => [
982 'key' => [ 'type' => 'string' ],
983 'value' => [],
984 'type' => [ 'type' => 'string' ],
985 ],
986 ],
987 ],
988 'message' => [ 'type' => 'string' ],
989 ],
990 'required' => [ 'success', 'message' ],
991 ],
992 'execute_callback' => function( $input = [] ) {
993 $user_id = isset( $input['user_id'] ) ? (int) $input['user_id'] : 0;
994 if ( $user_id <= 0 ) {
995 return [ 'success' => false, 'message' => 'user_id is required and must be a positive integer.' ];
996 }
997 if ( ! get_userdata( $user_id ) ) {
998 return [ 'success' => false, 'message' => sprintf( 'User %d not found.', $user_id ) ];
999 }
1000
1001 if ( isset( $input['keys'] ) && is_array( $input['keys'] ) && ! empty( $input['keys'] ) ) {
1002 $keys = array_values( array_filter( array_map( 'strval', $input['keys'] ) ) );
1003 $fields = [];
1004 foreach ( $keys as $key ) {
1005 $fields[] = [
1006 'key' => $key,
1007 'value' => get_user_meta( $user_id, $key, true ),
1008 'type' => 'raw',
1009 ];
1010 }
1011 return [
1012 'success' => true,
1013 'user_id' => $user_id,
1014 'mode' => 'targeted',
1015 'count' => count( $fields ),
1016 'fields' => $fields,
1017 'message' => sprintf( 'Read %d key(s) from user %d.', count( $fields ), $user_id ),
1018 ];
1019 }
1020
1021 $include_private = ! empty( $input['include_private'] );
1022 $all_meta = get_user_meta( $user_id );
1023 if ( ! is_array( $all_meta ) ) {
1024 $all_meta = [];
1025 }
1026
1027 $all_keys = array_keys( $all_meta );
1028 if ( ! $include_private ) {
1029 $all_keys = array_values( array_filter( $all_keys, function( $k ) {
1030 return strpos( $k, '_' ) !== 0;
1031 } ) );
1032 }
1033
1034 $total_keys = count( $all_keys );
1035 $cap = 100;
1036 $truncated = $total_keys > $cap;
1037 $work_keys = $truncated ? array_slice( $all_keys, 0, $cap ) : $all_keys;
1038
1039 $fields = [];
1040 foreach ( $work_keys as $key ) {
1041 $fields[] = [
1042 'key' => $key,
1043 'value' => get_user_meta( $user_id, $key, true ),
1044 'type' => 'raw',
1045 ];
1046 }
1047
1048 $msg = sprintf( 'Read %d of %d key(s) from user %d.', count( $fields ), $total_keys, $user_id );
1049 if ( $truncated ) {
1050 $msg .= ' Response truncated to 100 keys — pass specific keys in the "keys" parameter to read more.';
1051 }
1052
1053 return [
1054 'success' => true,
1055 'user_id' => $user_id,
1056 'mode' => 'all',
1057 'count' => count( $fields ),
1058 'truncated' => $truncated,
1059 'total_keys' => $total_keys,
1060 'fields' => $fields,
1061 'message' => $msg,
1062 ];
1063 },
1064 'permission_callback' => function() {
1065 return current_user_can( 'list_users' );
1066 },
1067 'meta' => [
1068 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1069 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ],
1070 ],
1071 ] );
1072
1073 // ---- update-user-meta ----
1074 wp_register_ability( 'atarim/update-user-meta', [
1075 'label' => 'Update User Meta',
1076 'description' => 'Writes a single user meta key/value. Generic post_meta-style write — no framework type detection on user meta this round. The value is passed directly to update_user_meta and serialized automatically if it\'s an array or object.',
1077 'category' => 'atarim',
1078 'input_schema' => [
1079 'type' => 'object',
1080 'properties' => [
1081 'user_id' => [
1082 'type' => 'integer',
1083 'description' => 'User ID.',
1084 'minimum' => 1,
1085 ],
1086 'key' => [
1087 'type' => 'string',
1088 'description' => 'Meta key.',
1089 'minLength' => 1,
1090 ],
1091 'value' => [
1092 'description' => 'New value.',
1093 ],
1094 ],
1095 'required' => [ 'user_id', 'key', 'value' ],
1096 'additionalProperties' => false,
1097 ],
1098 'output_schema' => [
1099 'type' => 'object',
1100 'properties' => [
1101 'success' => [ 'type' => 'boolean' ],
1102 'user_id' => [ 'type' => 'integer' ],
1103 'key' => [ 'type' => 'string' ],
1104 'new_value' => [],
1105 'message' => [ 'type' => 'string' ],
1106 ],
1107 'required' => [ 'success', 'message' ],
1108 ],
1109 'execute_callback' => function( $input = [] ) {
1110 $user_id = isset( $input['user_id'] ) ? (int) $input['user_id'] : 0;
1111 $key = isset( $input['key'] ) ? (string) $input['key'] : '';
1112 if ( $user_id <= 0 || $key === '' ) {
1113 return [ 'success' => false, 'message' => 'user_id and key are required.' ];
1114 }
1115 if ( ! array_key_exists( 'value', $input ) ) {
1116 return [ 'success' => false, 'message' => 'value is required.' ];
1117 }
1118 if ( ! get_userdata( $user_id ) ) {
1119 return [ 'success' => false, 'message' => sprintf( 'User %d not found.', $user_id ) ];
1120 }
1121 if ( ! current_user_can( 'edit_user', $user_id ) ) {
1122 return [ 'success' => false, 'message' => 'Permission denied.' ];
1123 }
1124
1125 $result = update_user_meta( $user_id, $key, $input['value'] );
1126 if ( $result === false ) {
1127 // update_user_meta returns false on "same value" OR failure; distinguish.
1128 $current = get_user_meta( $user_id, $key, true );
1129 if ( $current === $input['value'] ) {
1130 return [
1131 'success' => true,
1132 'user_id' => $user_id,
1133 'key' => $key,
1134 'new_value' => $current,
1135 'message' => sprintf( 'User meta "%s" already had this value; no change written.', $key ),
1136 ];
1137 }
1138 return [
1139 'success' => false,
1140 'user_id' => $user_id,
1141 'key' => $key,
1142 'message' => sprintf( 'Could not write user meta "%s" (WordPress reported failure).', $key ),
1143 ];
1144 }
1145
1146 return [
1147 'success' => true,
1148 'user_id' => $user_id,
1149 'key' => $key,
1150 'new_value' => get_user_meta( $user_id, $key, true ),
1151 'message' => sprintf( 'User meta "%s" updated for user %d.', $key, $user_id ),
1152 ];
1153 },
1154 'permission_callback' => function() {
1155 return current_user_can( 'edit_users' );
1156 },
1157 'meta' => [
1158 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1159 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => false ],
1160 ],
1161 ] );
1162 }
1163
1164 // ─── Detection and routing helpers ──────────────────────────────────────
1165
1166 /**
1167 * Detect which framework manages a given meta key on a given post.
1168 *
1169 * Returns one of: 'acf' | 'toolset' | 'meta_box' | 'raw'.
1170 *
1171 * Detection order (cheapest first):
1172 * 1. Toolset Types — keys with "wpcf-" prefix
1173 * 2. ACF — paired _fieldname meta whose value starts with "field_"
1174 * 3. Meta Box — registry walk for the post's type (strict: must be a registered field)
1175 * 4. raw — fallback
1176 *
1177 * The value parameter is currently unused but reserved — some frameworks
1178 * could be identified by value shape (e.g. ACF image arrays).
1179 *
1180 * @param int $post_id
1181 * @param string $key
1182 * @param mixed $value (reserved; currently unused)
1183 * @return string
1184 */
1185 private function avcf_detect_meta_type( $post_id, $key, $value ) {
1186 // 1. Toolset Types: prefix-based.
1187 if ( strpos( $key, 'wpcf-' ) === 0 ) {
1188 return 'toolset';
1189 }
1190
1191 // 2. ACF: paired field-key reference.
1192 // Skip the check entirely for keys starting with _ (those would be the sentinel meta themselves).
1193 if ( strpos( $key, '_' ) !== 0 ) {
1194 $sentinel = get_post_meta( $post_id, '_' . $key, true );
1195 if ( is_string( $sentinel ) && strpos( $sentinel, 'field_' ) === 0 ) {
1196 return 'acf';
1197 }
1198 }
1199
1200 // 3. Meta Box: strict registry check (Option A from design discussion).
1201 if ( $this->avcf_metabox_owns_field( $post_id, $key ) ) {
1202 return 'meta_box';
1203 }
1204
1205 return 'raw';
1206 }
1207
1208 /**
1209 * Check whether Meta Box has the given field registered for the given post's type.
1210 *
1211 * Returns true only when Meta Box's API is loaded AND the field is in a
1212 * registered meta-box for this post's type. Returns false in any other case
1213 * (Meta Box not active, field not registered, can't determine).
1214 *
1215 * @param int $post_id
1216 * @param string $key
1217 * @return bool
1218 */
1219 private function avcf_metabox_owns_field( $post_id, $key ) {
1220 // Sentinel: Meta Box exposes rwmb_get_object_fields or the RWMB_Loader class.
1221 if ( ! function_exists( 'rwmb_get_object_fields' ) ) {
1222 return false;
1223 }
1224 $post = get_post( $post_id );
1225 if ( ! $post ) {
1226 return false;
1227 }
1228 // rwmb_get_object_fields returns a flat array of fields keyed by id for a given object type.
1229 $fields = @rwmb_get_object_fields( $post->post_type, 'post' );
1230 if ( ! is_array( $fields ) ) {
1231 return false;
1232 }
1233 return isset( $fields[ $key ] );
1234 }
1235
1236 /**
1237 * Read a field's value via the appropriate backend.
1238 *
1239 * @param int $post_id
1240 * @param string $key
1241 * @param string $type acf|toolset|meta_box|raw
1242 * @param string $format For ACF only: 'raw' or 'formatted'. Ignored otherwise.
1243 * @return mixed
1244 */
1245 private function avcf_read_field_value( $post_id, $key, $type, $format = 'raw' ) {
1246 switch ( $type ) {
1247 case 'acf':
1248 if ( function_exists( 'get_field' ) ) {
1249 return get_field( $key, $post_id, $format === 'formatted' );
1250 }
1251 // Fallback if ACF not loaded — read raw.
1252 return get_post_meta( $post_id, $key, true );
1253
1254 case 'meta_box':
1255 if ( function_exists( 'rwmb_meta' ) ) {
1256 return rwmb_meta( $key, [], $post_id );
1257 }
1258 return get_post_meta( $post_id, $key, true );
1259
1260 case 'toolset':
1261 case 'raw':
1262 default:
1263 return get_post_meta( $post_id, $key, true );
1264 }
1265 }
1266
1267 /**
1268 * Write a field's value via the appropriate backend.
1269 *
1270 * Returns [ 'ok' => bool, 'error' => string|null ].
1271 *
1272 * @param int $post_id
1273 * @param string $key
1274 * @param mixed $value
1275 * @param string $type acf|toolset|meta_box|raw
1276 * @return array
1277 */
1278 private function avcf_write_field_value( $post_id, $key, $value, $type ) {
1279 switch ( $type ) {
1280 case 'acf':
1281 if ( function_exists( 'update_field' ) ) {
1282 $result = update_field( $key, $value, $post_id );
1283 if ( $result === false ) {
1284 return [ 'ok' => false, 'error' => 'ACF update_field returned false. The field may not exist for this post, or the field group may not be assigned.' ];
1285 }
1286 return [ 'ok' => true, 'error' => null ];
1287 }
1288 return [ 'ok' => false, 'error' => 'type=acf requested but ACF is not active on this site.' ];
1289
1290 case 'meta_box':
1291 if ( function_exists( 'rwmb_set_meta' ) ) {
1292 rwmb_set_meta( $post_id, $key, $value );
1293 return [ 'ok' => true, 'error' => null ];
1294 }
1295 // Fallback: raw write but warn the AI that Meta Box hooks didn't fire.
1296 update_post_meta( $post_id, $key, wp_slash( $value ) );
1297 return [ 'ok' => true, 'error' => null ];
1298
1299 case 'toolset':
1300 case 'raw':
1301 default:
1302 update_post_meta( $post_id, $key, wp_slash( $value ) );
1303 return [ 'ok' => true, 'error' => null ];
1304 }
1305 }
1306 /**
1307 * Write a framework field value on an options/settings page.
1308 * Currently handles ACF (the "option" store); other backends report a
1309 * clear unsupported message (seam for Meta Box settings pages / Pods / ACPT).
1310 *
1311 * @param string $field
1312 * @param mixed $value
1313 * @param string $ref ACF options post_id ("option" or a custom one)
1314 * @param string $type backend (currently only "acf")
1315 * @return array [ 'ok' => bool, 'error' => string|null ]
1316 */
1317 private function avcf_write_option_field_value( $field, $value, $ref, $type ) {
1318 switch ( $type ) {
1319 case 'acf':
1320 if ( function_exists( 'update_field' ) ) {
1321 $result = update_field( $field, $value, $ref );
1322 if ( $result === false ) {
1323 return [ 'ok' => false, 'error' => 'ACF update_field returned false — the field may not exist on this options page, or its field group is not assigned to the options page.' ];
1324 }
1325 return [ 'ok' => true, 'error' => null ];
1326 }
1327 return [ 'ok' => false, 'error' => 'type=acf requested but ACF is not active on this site.' ];
1328
1329 default:
1330 return [ 'ok' => false, 'error' => sprintf( 'Option/settings backend "%s" is not wired yet (only "acf" is supported this round; Meta Box settings pages, Pods, and ACPT option pages are planned).', $type ) ];
1331 }
1332 }
1333
1334 /**
1335 * Reject writes to security-sensitive internal meta keys.
1336 *
1337 * Two tiers:
1338 *
1339 * 1. Serialized attachment structures WordPress manages itself
1340 * (_wp_attachment_metadata, _wp_attachment_backup_sizes). Their file /
1341 * sizes[].file / backup entries drive on-disk path building and deletion in
1342 * core (wp_delete_attachment, the image editor's restore) and in other
1343 * abilities. Hand-writing them through a generic string field writer has no
1344 * legitimate use and can corrupt them or point file ops at attacker-chosen
1345 * paths, so they are refused outright.
1346 *
1347 * 2. _wp_attached_file — the attachment's uploads-relative path, resolved by
1348 * get_attached_file() for deletion/overwrite (e.g. atarim/replace-media-file).
1349 * A traversal ("../") or absolute value here escapes the uploads directory
1350 * (arbitrary file deletion, RCE via wp-config.php). Core only ever stores a
1351 * clean relative path, so anything else is refused. This is the write-source
1352 * guard complementing the containment check in the media abilities.
1353 *
1354 * @param string $field The meta key being written.
1355 * @param mixed $value The value to be written.
1356 * @return string|null Error message if the write must be rejected, otherwise null.
1357 */
1358 private function avcf_reject_unsafe_meta_write( $field, $value ) {
1359 // Tier 1: WordPress-managed attachment structures — never writable here.
1360 $reserved_structures = [
1361 '_wp_attachment_metadata', // file + sizes[].file drive path building / deletion
1362 '_wp_attachment_backup_sizes', // backup files unlinked on image-editor restore
1363 ];
1364 if ( in_array( $field, $reserved_structures, true ) ) {
1365 return sprintf(
1366 'Refused: "%s" is WordPress-managed attachment metadata and cannot be written through this ability. Use the dedicated media abilities (e.g. atarim/replace-media-file) instead.',
1367 $field
1368 );
1369 }
1370
1371 // Tier 2: _wp_attached_file — allow only a clean uploads-relative path.
1372 if ( '_wp_attached_file' === $field ) {
1373 if ( ! is_string( $value ) ) {
1374 return 'Refused: _wp_attached_file must be a plain uploads-relative path string.';
1375 }
1376
1377 $normalized = str_replace( '\\', '/', $value );
1378
1379 if (
1380 '' === $value
1381 || strpos( $value, "\0" ) !== false // null byte
1382 || strpos( $normalized, '../' ) !== false // parent traversal
1383 || '/' === substr( $normalized, 0, 1 ) // absolute (unix)
1384 || preg_match( '#^[a-zA-Z]:/#', $normalized ) // absolute (windows drive)
1385 || preg_match( '#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $normalized ) // stream wrapper / URL
1386 ) {
1387 return 'Refused: _wp_attached_file must be a relative path inside the uploads directory (no "..", absolute paths, or stream wrappers). This guards against file operations that resolve outside uploads.';
1388 }
1389 }
1390
1391 return null;
1392 }
1393 }