feat: explicit publish, unpublish, schedule and trash tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-25 23:18:35 +02:00
co-authored by Claude Opus 5.5
parent a9b2f2db71
commit 346619d6da
10 changed files with 902 additions and 0 deletions
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/post-publish: publishes an entry immediately. A scheduled ("future") or
* future-dated entry is republished now instead of staying in the future (spec §6.2);
* an already-published entry is a no-op.
*/
final class PostPublishAbility implements Ability
{
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/post-publish';
}
public function label(): string
{
return 'Veröffentlichen';
}
public function description(): string
{
return 'Veröffentlicht einen Eintrag sofort; geplante oder zukunftsdatierte Einträge erhalten das '
. 'aktuelle Datum. Nur auf ausdrücklichen Wunsch des Nutzers verwenden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
public function annotations(): array
{
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$post = $this->load($input);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = $this->load($input);
if ($post instanceof WP_Error) {
return $post;
}
$postId = (int) $post->ID;
$title = (string) $post->post_title;
$editUrl = (string) get_edit_post_link($postId, 'raw');
if ($post->post_status === 'publish') {
return ToolResult::ok(sprintf('"%s" ist bereits veröffentlicht.', $title))
->rev(Rev::ofPost($post))
->urls((string) get_permalink($postId), $editUrl)
->toArray();
}
$data = ['ID' => $postId, 'post_status' => 'publish'];
if ($post->post_status === 'future' || $this->isFutureDated($post)) {
$data['post_date'] = current_time('mysql');
$data['post_date_gmt'] = current_time('mysql', true);
$data['edit_date'] = true;
}
$result = wp_update_post($data, true);
if ($result instanceof WP_Error) {
return Errors::invalidInput(
'WordPress hat das Veröffentlichen abgelehnt: ' . $result->get_error_message()
);
}
$status = get_post_status($postId);
if ($status !== 'publish') {
return Errors::unsupported(
sprintf('Nach dem Veröffentlichen ist der Status "%s".', (string) $status),
'Eintrag in WordPress prüfen (z. B. Freigabe-Workflow eines anderen Plugins).'
);
}
// The status change is saved – failures from here on are logged and reported as warnings only.
$warnings = [];
FollowUp::run(
fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input),
$this->name() . ' lit_mcp_after_save',
'Eine Erweiterung ist nach dem Veröffentlichen auf einen Fehler gestoßen; die Änderung ist gespeichert.',
$warnings
);
$summary = sprintf('"%s" veröffentlicht', $title);
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$warnings
);
$updated = get_post($postId);
$updated = $updated instanceof WP_Post ? $updated : $post;
return ToolResult::ok(sprintf('"%s" ist jetzt veröffentlicht.', $title))
->changed((string) $postId, (string) $post->post_type, 'published', $title)
->rev(Rev::ofPost($updated))
->logId(is_int($logId) ? $logId : null)
->urls((string) get_permalink($postId), $editUrl)
->warnings($warnings)
->toArray();
}
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return current_user_can('publish_post', (int) $post->ID)
? $post
: Errors::forbidden('Keine Berechtigung, diesen Eintrag zu veröffentlichen.');
}
private function isFutureDated(WP_Post $post): bool
{
$gmt = (string) $post->post_date_gmt;
return $gmt !== '' && $gmt !== '0000-00-00 00:00:00' && strtotime($gmt . ' UTC') > time();
}
}
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use DateTimeImmutable;
use DateTimeZone;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/post-schedule: schedules an entry for publication at a future point in time
* (site local time). An entry that is currently published stays live until then only
* if wp_update_post() keeps it that way; WordPress itself takes it offline once the
* status becomes "future".
*/
final class PostScheduleAbility implements Ability
{
private const FORMAT = 'Y-m-d H:i';
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/post-schedule';
}
public function label(): string
{
return 'Veröffentlichung planen';
}
public function description(): string
{
return 'Plant die Veröffentlichung eines Eintrags zu einem Zeitpunkt in der Zukunft (Ortszeit der '
. 'Website). Ein bereits veröffentlichter Eintrag ist bis dahin offline. Nur auf ausdrücklichen '
. 'Wunsch des Nutzers verwenden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'date' => [
'type' => 'string',
'pattern' => '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$',
'description' => 'JJJJ-MM-TT HH:MM in der Zeitzone der Website.',
],
],
'required' => ['post_id', 'date'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
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
{
$post = $this->load($input);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = $this->load($input);
if ($post instanceof WP_Error) {
return $post;
}
$raw = (string) ($input['date'] ?? '');
$date = DateTimeImmutable::createFromFormat('!' . self::FORMAT, $raw, wp_timezone());
if ($date === false || $date->format(self::FORMAT) !== $raw) {
return Errors::invalidInput('Ungültiges Datum.', ['date' => 'Format JJJJ-MM-TT HH:MM erwartet']);
}
if ($date->getTimestamp() <= time()) {
return Errors::invalidInput(
'Der Zeitpunkt muss in der Zukunft liegen.',
['date' => 'liegt nicht in der Zukunft'],
'Zum sofortigen Veröffentlichen lit-post-publish verwenden.'
);
}
$postId = (int) $post->ID;
$result = wp_update_post([
'ID' => $postId,
'post_status' => 'future',
'post_date' => $date->format('Y-m-d H:i:s'),
'post_date_gmt' => $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'),
'edit_date' => true,
], true);
if ($result instanceof WP_Error) {
return Errors::invalidInput('WordPress hat das Planen abgelehnt: ' . $result->get_error_message());
}
if (get_post_status($postId) !== 'future') {
return Errors::unsupported(
'WordPress hat den Eintrag nicht als geplant gespeichert.',
'Eintrag in WordPress prüfen (z. B. Freigabe-Workflow eines anderen Plugins).'
);
}
// The status change is saved – failures from here on are logged and reported as warnings only.
$warnings = [];
FollowUp::run(
fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input),
$this->name() . ' lit_mcp_after_save',
'Eine Erweiterung ist nach dem Planen auf einen Fehler gestoßen; die Änderung ist gespeichert.',
$warnings
);
$title = (string) $post->post_title;
$summary = sprintf('"%s" geplant für %s', $title, $raw)
. ($post->post_status === 'publish' ? ' (war veröffentlicht und ist bis dahin offline)' : '');
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$warnings
);
$updated = get_post($postId);
$updated = $updated instanceof WP_Post ? $updated : $post;
return ToolResult::ok($summary . '.')
->changed((string) $postId, (string) $post->post_type, 'scheduled', $title)
->rev(Rev::ofPost($updated))
->logId(is_int($logId) ? $logId : null)
->urls((string) get_preview_post_link($updated), (string) get_edit_post_link($postId, 'raw'))
->warnings($warnings)
->toArray();
}
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return current_user_can('publish_post', (int) $post->ID)
? $post
: Errors::forbidden('Keine Berechtigung, diesen Eintrag zu veröffentlichen oder zu planen.');
}
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/post-trash: moves an entry to the trash – never a permanent delete. When the
* trash itself is disabled (EMPTY_TRASH_DAYS = 0), wp_trash_post() would delete the
* entry permanently (wp-includes/post.php: `if ( ! EMPTY_TRASH_DAYS ) return
* wp_delete_post( $post_id, true );`), so this tool refuses instead of calling it.
*/
final class PostTrashAbility implements Ability
{
/** @var callable(): bool */
private $trashEnabled;
public function __construct(
private ContentSchema $schema,
private ChangeLog $log,
?callable $trashEnabled = null
) {
$this->trashEnabled = $trashEnabled
?? static fn (): bool => ! defined('EMPTY_TRASH_DAYS') || (bool) EMPTY_TRASH_DAYS;
}
public function name(): string
{
return 'lit/post-trash';
}
public function label(): string
{
return 'In den Papierkorb';
}
public function description(): string
{
return 'Legt einen Eintrag in den Papierkorb (nie endgültig löschen; wiederherstellbar in WordPress). '
. 'Erst nach Rückfrage beim Nutzer mit confirm: true aufrufen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'confirm' => ['type' => 'boolean', 'description' => 'true nach Bestätigung durch den Nutzer.'],
],
'required' => ['post_id'],
'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
{
$post = $this->load($input);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = $this->load($input);
if ($post instanceof WP_Error) {
return $post;
}
$postId = (int) $post->ID;
$title = (string) $post->post_title;
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired(sprintf('"%s" (ID %d) in den Papierkorb legen?', $title, $postId));
}
if ($post->post_status === 'trash') {
return ToolResult::ok(sprintf('"%s" liegt bereits im Papierkorb.', $title))
->rev(Rev::ofPost($post))
->toArray();
}
if (! ($this->trashEnabled)()) {
return Errors::unsupported(
'Der Papierkorb ist auf dieser Website deaktiviert (EMPTY_TRASH_DAYS = 0); '
. 'WordPress würde endgültig löschen. Nichts wurde geändert.',
'Endgültiges Löschen ist über LIT MCP nicht möglich; der Nutzer kann es in WordPress selbst tun.'
);
}
if (! wp_trash_post($postId)) {
return Errors::unsupported(
'WordPress hat das Verschieben in den Papierkorb abgelehnt.',
'Eintrag in WordPress prüfen (ein anderes Plugin kann das Verschieben verhindern).'
);
}
// The change is saved – a failure of the change log from here on is a warning only.
$summary = sprintf('"%s" in den Papierkorb gelegt', $title);
$warnings = [];
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$warnings
);
$updated = get_post($postId);
$updated = $updated instanceof WP_Post ? $updated : $post;
return ToolResult::ok(sprintf('"%s" liegt im Papierkorb und ist dort wiederherstellbar.', $title))
->changed((string) $postId, (string) $post->post_type, 'trashed', $title)
->rev(Rev::ofPost($updated))
->logId(is_int($logId) ? $logId : null)
->warnings($warnings)
->toArray();
}
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
return current_user_can('delete_post', (int) $post->ID)
? $post
: Errors::forbidden('Keine Berechtigung, diesen Eintrag zu löschen.');
}
}
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* lit/post-unpublish: withdraws a published, scheduled or private entry back to draft.
* An entry that is already not public (draft, pending, auto-draft) is a no-op.
*/
final class PostUnpublishAbility implements Ability
{
private const NOT_PUBLIC = ['draft', 'pending', 'auto-draft'];
public function __construct(private ContentSchema $schema, private ChangeLog $log)
{
}
public function name(): string
{
return 'lit/post-unpublish';
}
public function label(): string
{
return 'Zurückziehen';
}
public function description(): string
{
return 'Setzt einen veröffentlichten, geplanten oder privaten Eintrag zurück auf Entwurf. '
. 'Nur auf ausdrücklichen Wunsch des Nutzers verwenden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer', 'minimum' => 1]],
'required' => ['post_id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema();
}
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
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
if ($post instanceof WP_Error) {
return $post;
}
$postId = (int) $post->ID;
$title = (string) $post->post_title;
$editUrl = (string) get_edit_post_link($postId, 'raw');
if (in_array($post->post_status, self::NOT_PUBLIC, true)) {
return ToolResult::ok(sprintf('"%s" ist nicht veröffentlicht (Status: %s).', $title, $post->post_status))
->rev(Rev::ofPost($post))
->urls((string) get_preview_post_link($post), $editUrl)
->toArray();
}
$result = wp_update_post(['ID' => $postId, 'post_status' => 'draft'], true);
if ($result instanceof WP_Error) {
return Errors::invalidInput('WordPress hat das Zurückziehen abgelehnt: ' . $result->get_error_message());
}
// The status change is saved – failures from here on are logged and reported as warnings only.
$warnings = [];
FollowUp::run(
fn () => do_action('lit_mcp_after_save', $postId, $this->name(), $input),
$this->name() . ' lit_mcp_after_save',
'Eine Erweiterung ist nach dem Zurückziehen auf einen Fehler gestoßen; die Änderung ist gespeichert.',
$warnings
);
$summary = sprintf('"%s" zurückgezogen (vorher %s, jetzt Entwurf)', $title, $post->post_status);
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), (string) $post->post_type, (string) $postId, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Änderung ist gespeichert.',
$warnings
);
$updated = get_post($postId);
$updated = $updated instanceof WP_Post ? $updated : $post;
return ToolResult::ok($summary . '.')
->changed((string) $postId, (string) $post->post_type, 'unpublished', $title)
->rev(Rev::ofPost($updated))
->logId(is_int($logId) ? $logId : null)
->urls((string) get_preview_post_link($updated), $editUrl)
->warnings($warnings)
->toArray();
}
}