Files
WP-LIT-MCP/src/Integrations/WordPress/Revisions/RevisionRestoreAbility.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

159 lines
5.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\Revisions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostChangeResult;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\RevisionPoint;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/revision-restore: restores title, content and excerpt of an entry from one of
* its own revisions; the status is not touched. Because this overwrites the current
* content, rev of the current entry (from lit/content-get) is required (optimistic
* lock, spec §2 principle 7) – the restore is itself a write. Before restoring, the
* current state is saved as a revision (RevisionPoint::capture), so the restore is
* undoable via another lit/revision-restore call. A revision id that does not belong
* to a manageable entry (wrong post_type, or post_parent 0 – an orphaned revision) is
* rejected as a validation error on revision_id, the only id field this tool takes.
*/
final class RevisionRestoreAbility implements Ability
{
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/revision-restore';
}
public function label(): string
{
return 'Revision wiederherstellen';
}
public function description(): string
{
return 'Stellt Titel, Inhalt und Auszug eines Eintrags aus einer Revision wieder her (Status bleibt). '
. 'Der aktuelle Stand wird vorher als Revision gesichert (revision_id im Ergebnis). rev des Eintrags '
. 'aus lit-content-get ist Pflicht, damit parallele Änderungen nicht überschrieben werden. Erst nach '
. 'Rückfrage mit confirm: true aufrufen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'revision_id' => ['type' => 'integer', 'minimum' => 1],
'rev' => [
'type' => 'string',
'description' => 'rev des Eintrags aus lit-content-get (optimistische Sperre).',
],
'confirm' => ['type' => 'boolean', 'description' => 'true nach Bestätigung durch den Nutzer.'],
],
'required' => ['revision_id', 'rev'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => true, 'idempotent' => true];
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$pair = $this->load($input);
return $pair instanceof WP_Error ? $pair : true;
}
public function execute(array $input): mixed
{
$pair = $this->load($input);
if ($pair instanceof WP_Error) {
return $pair;
}
[$revision, $post] = $pair;
$postId = (int) $post->ID;
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(sprintf(
'"%s" (ID %d) auf den Stand vom %s zurücksetzen?',
$post->post_title,
$postId,
$revision->post_date
));
}
if (! Rev::matches($input, $post)) {
return Errors::revMismatch();
}
// wp_restore_post_revision() saves via wp_update_post(), which would publish a missed schedule.
$unstable = PostGuard::statusStable($post, 'revision_id');
if ($unstable !== null) {
return $unstable;
}
$backupId = RevisionPoint::capture($postId);
$restored = wp_restore_post_revision((int) $revision->ID);
if (! $restored) {
return Errors::writeFailed('WordPress konnte die Revision nicht wiederherstellen.');
}
// The restore is saved – failures from here on are logged and reported as warnings only.
$change = (new PostChangeResult($this->name(), $this->log, $postId))->afterSave($input, 'Wiederherstellen');
$updated = $change->reread($post) ?? $post;
$summary = sprintf('"%s" auf Stand vom %s zurückgesetzt', $updated->post_title, $revision->post_date);
$postType = (string) $post->post_type;
$change->log($postType, $summary, $backupId);
return $change->result($summary . sprintf(' (Status unverändert: %s).', $updated->post_status), $updated)
->changed((string) $postId, $postType, 'restored', (string) $updated->post_title)
->revisionId($backupId)
->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw'))
->toArray();
}
/**
* @param array<string, mixed> $input
* @return array{0: WP_Post, 1: WP_Post}|WP_Error
*/
private function load(array $input): array|WP_Error
{
$revisionId = PostGuard::id($input['revision_id'] ?? null);
$revision = $revisionId === null ? null : get_post($revisionId);
if (! $revision instanceof WP_Post || $revision->post_type !== 'revision') {
return Errors::notFound('Revision nicht gefunden.', 'Revisionen über lit/revisions-list abrufen.');
}
// field 'revision_id': post_parent 0 (an orphaned revision) is the only id this
// input carries, so a validation error must name revision_id, not post_id.
$post = PostGuard::editable((int) $revision->post_parent, $this->schema, 'revision_id');
return $post instanceof WP_Error ? $post : [$revision, $post];
}
}