> $registry Registry. * @return array> */ function aafm_register_settings_definitions( array $registry ): array { $registry['aafm/get-site-settings'] = array( 'label' => __( 'Get site settings', 'agent-abilities-for-mcp' ), 'description' => __( 'Read a small allowlist of site settings: name, tagline, timezone, date and time formats, week start, and posts per page. Requires the manage-options capability. Never the site URL or admin email.', 'agent-abilities-for-mcp' ), 'group' => 'reads', 'risk' => 'read', 'subject' => 'site', 'args_builder' => 'aafm_args_get_site_settings', ); $registry['aafm/update-site-settings'] = array( 'label' => __( 'Update site settings', 'agent-abilities-for-mcp' ), 'description' => __( 'Write a small allowlist of site settings, passed as a settings object whose keys are the setting names and values the new values. Accepted keys: blogname (site name), blogdescription (tagline), timezone_string, date_format, time_format, start_of_week (0-6), posts_per_page (1-100). String values are sanitized; the two integer settings are clamped into their legal ranges. Any unrecognized key rejects the entire call with nothing written. It can never change the site URL, admin email, default role, or open registration. Requires manage-options. Off by default.', 'agent-abilities-for-mcp' ), 'group' => 'writes', 'risk' => 'destructive', 'subject' => 'site', 'args_builder' => 'aafm_args_update_site_settings', ); return $registry; } /** * Permission for the site-settings abilities: manage_options. * * The same capability WordPress gates the Settings screen behind. A caller without it is * denied (and audited) before any option is read or written. The check is object- * independent, so discovery can fall through to this callback with no server.php case. * * @return bool */ function aafm_perm_manage_options(): bool { return current_user_can( 'manage_options' ); } /** * Args for aafm/get-site-settings. * * @return array */ function aafm_args_get_site_settings(): array { return array( 'label' => aafm_ability_label( 'aafm/get-site-settings' ), 'description' => aafm_ability_description( 'aafm/get-site-settings' ), 'category' => 'aafm-reads', 'input_schema' => array( 'type' => 'object', 'properties' => array(), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'settings' => array( 'type' => 'object' ), ), ), 'execute_callback' => 'aafm_exec_get_site_settings', 'permission_callback' => 'aafm_perm_manage_options', 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), ), ); } /** * Execute aafm/get-site-settings. * * Returns each allowlisted setting's current value as a key/value map. Only the fixed * allowlist is ever read, so the takeover-class keys can never appear in the output. * * @return array */ function aafm_exec_get_site_settings(): array { $out = array(); foreach ( aafm_allowed_site_settings() as $key ) { $out[ $key ] = get_option( $key ); } // aafm_allowed_site_settings() is filterable (helpers.php:583-592). A site that hooks the // filter down to an empty list - e.g. add_filter( 'aafm_allowed_site_settings', // '__return_empty_array' ), the natural way to stop exposing settings without disabling // the ability - makes $out empty. An empty PHP array encodes as [], not {}, and the // adapter passes the return value verbatim into structuredContent, which would violate the // declared object schema. Cast so the map still JSON-encodes to {} in that case. return array( 'settings' => array() === $out ? (object) $out : $out ); } /** * Args for aafm/update-site-settings. * * The schema is closed at the top level (additionalProperties:false) and the nested * settings object is validated server-side against the allowlist. The ability is * annotated destructive:true - a settings change is permanent and site-wide. * * @return array */ function aafm_args_update_site_settings(): array { return array( 'label' => aafm_ability_label( 'aafm/update-site-settings' ), 'description' => aafm_ability_description( 'aafm/update-site-settings' ), 'category' => 'aafm-writes', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'settings' => array( 'type' => 'object', 'description' => __( 'A map of setting name to new value. Allowed keys: blogname, blogdescription, timezone_string, date_format, time_format, start_of_week, posts_per_page. Any other key rejects the whole call.', 'agent-abilities-for-mcp' ), ), ), 'required' => array( 'settings' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'settings' => array( 'type' => 'object' ), ), ), 'execute_callback' => 'aafm_exec_update_site_settings', 'permission_callback' => 'aafm_perm_manage_options', 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => true, ), ), ); } /** * Execute aafm/update-site-settings. * * Fail-closed: every submitted key is checked against the allowlist BEFORE any write, and * a single non-allowlisted key rejects the whole call - there is no partial apply and no * raw update_option() of an arbitrary key. A non-scalar value (array/object) is likewise * refused outright before any write, so a structure can never be stored. Values are sanitized * per type; the two integers are clamped into their legal ranges server-side (posts_per_page * floored to >=1 and capped at 100 so a 0 cannot break WP_Query; start_of_week clamped to 0..6). * A value core's own sanitize_option() would silently revert (e.g. an invalid timezone_string) * is caught with a dry run before any write, so the call errors instead of reporting a * success that never actually changed the setting. * * @param array $input Validated input. * @return array|WP_Error */ function aafm_exec_update_site_settings( array $input ) { $settings = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : array(); if ( array() === $settings ) { return aafm_generic_error(); } $allow = aafm_allowed_site_settings(); // Fail closed: any key not on the allowlist rejects the whole call before a single write. // The error names the rejected key and the allowed set so the agent can correct the call. foreach ( array_keys( $settings ) as $key ) { if ( ! in_array( (string) $key, $allow, true ) ) { return new WP_Error( 'aafm_setting_not_allowed', sprintf( /* translators: 1: the rejected setting key, 2: the comma-separated list of allowed setting keys. */ __( 'The setting "%1$s" cannot be changed. Allowed settings are: %2$s.', 'agent-abilities-for-mcp' ), (string) $key, implode( ', ', $allow ) ) ); } } // Scalar-only with a string-type guard: a non-scalar value (array/object) is refused outright, // and a boolean is refused for the string-typed settings (sending true/false for a name or // format is almost always a mistake and would silently store "1"/""). The two integer // settings DO accept a boolean, since the int clamp turns it into a sane 0/1 in range. $integer_keys = array( 'posts_per_page', 'start_of_week' ); foreach ( $settings as $key => $value ) { if ( ! is_scalar( $value ) ) { return aafm_generic_error(); } if ( is_bool( $value ) && ! in_array( (string) $key, $integer_keys, true ) ) { return new WP_Error( 'aafm_setting_bad_type', sprintf( /* translators: %s: the setting key that received a boolean value. */ __( 'The setting "%s" expects a text value, not true/false.', 'agent-abilities-for-mcp' ), (string) $key ) ); } } // Fourth pass: some allowlisted keys (timezone_string) are additionally validated by // WordPress's own sanitize_option() at write time. On an invalid value core does not // error out visibly to us - it silently REVERTS to whatever the option already held. // Left unchecked this ability would report success on a write that never happened. // Dry-run sanitize_option() against our own sanitized value before writing anything, // so a value core would reject fails the whole call instead - keeping the same // no-partial-apply guarantee as the allowlist and type checks above. foreach ( $settings as $key => $value ) { $key = (string) $key; $intended = aafm_sanitize_site_setting( $key, $value ); $current = get_option( $key ); if ( $intended === $current ) { continue; // No-op write; nothing for core to silently revert. } if ( sanitize_option( $key, $intended ) === $current ) { // sanitize_option() landing on the current stored value means one of two things, and they // need opposite handling. Core may have ESCAPED a valid value into its stored form (core // stores blogname "Bob's Store" as "Bob's Store"), which is a genuine no-op, or core // may have REJECTED an invalid value and reverted to current. Decode the stored value: if // it matches the intended value the caller sent, it was a valid escape, so skip it as a // no-op. Only a real revert, where the decoded current still differs from intended, is an // error. if ( is_string( $current ) && is_string( $intended ) && wp_specialchars_decode( $current, ENT_QUOTES ) === $intended ) { continue; } return new WP_Error( 'aafm_setting_rejected', sprintf( /* translators: %s: the setting key WordPress rejected as invalid. */ __( 'The value for "%s" is not valid and was not applied.', 'agent-abilities-for-mcp' ), $key ) ); } } // A stale persistent cache can make update_option() skip or misdirect a write, because it decides // from get_option()'s answer, which comes from a cache copy before the row. So before anything is // written, every cache copy of each key has to agree with its row (see // aafm_option_row_if_cache_agrees() for the rules). A refusal on any key refuses the whole request // before the first write. The check runs after the dry-run's reads on purpose, so a notoptions // entry that a failed read there leaves is refused; it must stay the last read before the first // write. foreach ( array_keys( $settings ) as $key ) { if ( null === aafm_option_row_if_cache_agrees( (string) $key ) ) { return aafm_generic_error(); } } $updated = array(); foreach ( $settings as $key => $value ) { $key = (string) $key; aafm_option_write( $key, aafm_sanitize_site_setting( $key, $value ) ); // Report the value the option layer gives back right after the write, rather than echoing // the submission. That is what makes the clamp, the sanitize, and core's own escaping of a // stored value visible to the agent, which is why the read-back is here. // // It is NOT a guarantee that the response equals the row just written. get_option() runs // the option_{$key} filters, so whatever is hooked there decides what comes back. That is // the whole bound, and it holds for any filtered option. A translation layer is the case // that turns up in practice. // // DOCUMENTED by the vendor, not inferred from our own reproduction: WPML String // Translation "filters theme/plugin setting options in order to return translations of // their values in the current language", and its own docs name a site's tagline as one of // the strings it handles. So on such a site this reports the current language's // translation. WPML also documents wpml_unfiltered_admin_string as the supported way to // read past its filters; this ability deliberately does not reach for it, because the // bound above is not one vendor's to answer for, and a read-back that special-cases a // vendor is a worse promise than one that admits its limit. See // wpml.org/documentation/support/wpml-coding-api/wpml-hooks-reference and // wpml.org/documentation/support/wpml-tables. // // OBSERVED ONLY, on one clone running String Translation 3.5.3 with SitePress 4.9.6: // blogname and blogdescription come back one write behind, and the next request reads the // new value. The one-write-behind sequencing is measured here, not promised by WPML. $updated[ $key ] = get_option( $key ); } return array( 'settings' => $updated ); } /** * Sanitize and clamp one allowlisted site-setting value by its type. * * Note absint() is deliberately NOT used for the integer bounds: it returns the ABSOLUTE value, * so absint('-3') is 3, not 0 - it would silently flip a negative into a live limit. The * floor/cap form (min(max, max(floor, (int) $raw))) clamps correctly. The string settings * run through aafm_sanitize_plain_text. * * @param string $key An allowlisted settings key (the caller has already proven this). * @param mixed $value Raw submitted value. * @return int|string Clamped/sanitized value ready for update_option. */ function aafm_sanitize_site_setting( string $key, $value ) { if ( 'posts_per_page' === $key ) { // Floor to >=1 (a 0 breaks WP_Query) and cap at 100 to avoid an unbounded query. return min( 100, max( 1, (int) $value ) ); } if ( 'start_of_week' === $key ) { // WordPress stores 0 (Sunday) .. 6 (Saturday); clamp so a 99 can never be persisted. return min( 6, max( 0, (int) $value ) ); } // The site identity is the most feed-visible text on the whole site: blogname and // blogdescription go straight into the RSS and Atom headers, so a raw NUL here breaks every // feed document, not one item in it. Same defect as a post title, one level up. return aafm_sanitize_plain_text( (string) $value ); }