Files
WP-LIT-MCP/src/Integrations/WordPress/Settings/WpSettingsUpdateAbility.php
T
janniclas.loosenandClaude Opus 5.5 fea75b03d6 docs: name tools and error codes the way the client sees them
Indirect abilities are written lit/<name> (via mcp-adapter-execute-ability) in
guides, descriptions and hints, direct tools lit-<name>; guides use the
client-visible codes (write_failed, partial_write) and say exactly which tools
return a revision_id. ToolNamingTest keeps guides and sources consistent with
Ability::isDirect() and checks the overview's direct-tool list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 01:28:18 +02:00

277 lines
9.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Settings;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use RuntimeException;
use Throwable;
use WP_Error;
/**
* lit/wp-settings-update: writes whitelisted WordPress options, all or nothing.
*
* Every value is validated against SettingsWhitelist first; nothing is written if any
* value is invalid. "At least one setting" is enforced here in code rather than with the
* JSON Schema keyword `minProperties`: the plan's Global Constraints list only the JSON
* Schema keywords rest_validate_value_from_schema() checks for this project
* (type/properties/required/items/enum/minimum/maximum/minLength/maxLength/pattern/
* minItems/maxItems/additionalProperties) and `minProperties` is not among them, even
* though WordPress core's REST validator does support it (controller ruling C6).
*/
final class WpSettingsUpdateAbility implements Ability
{
public function __construct(private SettingsWhitelist $whitelist, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/wp-settings-update';
}
public function label(): string
{
return 'WordPress-Einstellungen ändern';
}
public function description(): string
{
return 'Ändert freigegebene WordPress-Einstellungen (Schlüssel siehe lit/wp-settings-get). Alle Werte '
. 'werden vorher geprüft – bei einem Fehler wird nichts gespeichert. previous enthält die alten Werte. '
. 'Gesperrt: admin_email, users_can_register, default_role, siteurl, home.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'settings' => [
'type' => 'object',
'description' => 'Schlüssel → neuer Wert, mindestens einer, z. B. {"blogname": "Praxis Muster"}.',
],
],
'required' => ['settings'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(['previous' => ['type' => 'object']], ['previous']);
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('manage_options')
? true
: Errors::forbidden('Keine Berechtigung für WordPress-Einstellungen (manage_options).');
}
public function execute(array $input): mixed
{
$settings = is_array($input['settings'] ?? null) ? $input['settings'] : [];
if ($settings === []) {
return Errors::invalidInput('Keine Einstellungen angegeben.', ['settings' => 'mindestens ein Schlüssel']);
}
$errors = $this->validateAll($settings);
if ($errors !== []) {
return Errors::invalidInput(
'Einstellungen ungültig; nichts wurde gespeichert.',
$errors,
'Werte gemäß fields korrigieren; aktuelle Werte über lit/wp-settings-get.'
);
}
return $this->write($settings);
}
/**
* @param array<string, mixed> $settings
* @return array<string, string>
*/
private function validateAll(array $settings): array
{
$errors = [];
foreach ($settings as $key => $value) {
$error = $this->whitelist->validate((string) $key, $value, $settings);
if ($error !== null) {
$errors[(string) $key] = $error;
}
}
return $errors;
}
/**
* All values are already known-valid; writes only the ones that actually change.
* If a write fails partway through, the already-written keys are restored to their
* previous values so the call stays all-or-nothing.
*
* @param array<string, mixed> $settings
* @return array<string, mixed>
* @throws WriteFailed When a write fails (after restoring what it could).
*/
private function write(array $settings): array
{
$previous = [];
foreach ($settings as $key => $value) {
$previous[(string) $key] = $this->whitelist->current((string) $key);
}
$changed = [];
try {
foreach ($settings as $key => $value) {
$key = (string) $key;
if ($this->isStored($key, $this->whitelist->normalize($key, $value))) {
continue;
}
$this->writeOne($key, $value);
$changed[] = $key;
}
} catch (Throwable $e) {
$this->fail($changed, $previous, $e);
}
if ($changed === []) {
return ToolResult::ok('Keine Änderung – alle Werte waren bereits gesetzt.')
->with('previous', JsonMap::of($previous))
->toArray();
}
// The settings are saved – a failure of the change log from here on is a warning only.
$summary = 'Einstellungen geändert: ' . implode(', ', $changed);
$warnings = [];
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), 'option', implode(',', $changed), $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Einstellungen sind gespeichert.',
$warnings
);
$result = ToolResult::ok($summary . '.');
foreach ($changed as $key) {
$result->changed($key, 'option', 'updated', SettingsWhitelist::SETTINGS[$key][2]);
}
return $result
->logId(is_int($logId) ? $logId : null)
->warnings($warnings)
->with('previous', JsonMap::of($previous))
->toArray();
}
private function writeOne(string $key, mixed $value): void
{
$normalized = $this->whitelist->normalize($key, $value);
if ($key === 'permalink_structure') {
$this->setPermalinkStructure((string) $normalized);
return;
}
// update_option() returns false both for a genuine failure and for a value
// that already matches the stored option (WordPress core short-circuits the
// write in that case, see wp-includes/option.php) – get_option() afterwards
// tells the two apart, the same way MetaFieldSource::holds() does for meta.
if (update_option($key, $normalized) === false && ! $this->isStored($key, $normalized)) {
throw new RuntimeException(sprintf('update_option(%s) failed', $key));
}
}
/**
* Whether option $key already holds $normalized in the form WordPress stores it:
* update_option() runs sanitize_option() first (blogname/blogdescription are esc_html()'d),
* so the stored value is compared with the sanitized one, like WordPress compares scalars.
*/
private function isStored(string $key, mixed $normalized): bool
{
$stored = get_option($key);
$expected = sanitize_option($key, $normalized);
if (is_scalar($stored) && is_scalar($expected)) {
return (string) $stored === (string) $expected;
}
return $stored == $expected; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
}
/**
* A write failed; restores the keys already written to their previous values and throws
* WriteFailed: write_failed (fully restored) or partial_write (some keys could not be
* restored – the client is told which, so it can check and correct them; logged as partial).
*
* @param list<string> $changed Keys written so far, in write order.
* @param array<string, mixed> $previous Their values before this call.
* @throws WriteFailed Always.
*/
private function fail(array $changed, array $previous, Throwable $cause): never
{
$stuck = [];
foreach (array_reverse($changed) as $key) {
try {
$this->writeOne($key, $previous[$key]);
} catch (Throwable $e) {
$stuck[] = $key;
}
}
$reason = 'Eine Einstellung konnte nicht gespeichert werden.';
if ($stuck === []) {
throw new WriteFailed($reason, true, null, null, $cause);
}
$this->log->recordPartial(
$this->name(),
'option',
implode(',', $stuck),
'Einstellungen nur teilweise zurückgenommen: ' . implode(', ', $stuck)
);
throw new WriteFailed(
$reason,
false,
null,
null,
$cause,
$cause->getMessage() . '; not restored: ' . implode(', ', $stuck),
sprintf('Möglicherweise auf dem neuen, nicht vollständigen Stand: %s.', implode(', ', $stuck)),
'Aktuelle Werte über lit/wp-settings-get (mcp-adapter-execute-ability) prüfen und ggf. erneut auf die '
. 'gewünschten Werte setzen.',
['keys' => $stuck]
);
}
private function setPermalinkStructure(string $structure): void
{
global $wp_rewrite;
$wp_rewrite->set_permalink_structure($structure);
flush_rewrite_rules();
// set_permalink_structure() has no return value to check; verify the option
// itself so a failure here is caught and rolled back like any other setting.
if (! $this->isStored('permalink_structure', $structure)) {
throw new RuntimeException('set_permalink_structure() did not update the stored value.');
}
}
}