From c383b99e7d87dbeab66fd05fd7079c7f17258b36 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 6 Nov 2026 14:50:12 +0100 Subject: [PATCH] feat(changelog): Hook lit_mcp_changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChangeLog::record() löst nach jeder erfolgreichen oder teilweise gespeicherten Änderung lit_mcp_changed(ability, objectType, objectId, logId) aus, nie bei abgelehnten. Die Werte sind gekürzt wie im Protokoll; Ausnahmen von Listenern werden protokolliert und ändern das Ergebnis des Tools nicht. Co-Authored-By: Claude Opus 5.5 --- src/ChangeLog/ChangeLog.php | 41 ++++++++-- tests/Unit/ChangeLog/ChangeLogTest.php | 101 +++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/ChangeLog/ChangeLog.php b/src/ChangeLog/ChangeLog.php index 065922e..1390b8a 100644 --- a/src/ChangeLog/ChangeLog.php +++ b/src/ChangeLog/ChangeLog.php @@ -18,6 +18,9 @@ class ChangeLog { public const TABLE = 'lit_mcp_log'; + /** Fires after every write that changed something (result ok or partial), see announce(). */ + public const CHANGED_ACTION = 'lit_mcp_changed'; + public function __construct(private ?\wpdb $db = null) { } @@ -42,15 +45,19 @@ class ChangeLog string $result = 'ok' ): ?int { $wpdb = $this->db(); + // Shortened once: the row and the announcement carry the same values. + $ability = mb_substr($ability, 0, 100); + $objectType = mb_substr($objectType, 0, 50); + $objectId = mb_substr($objectId, 0, 191); $inserted = $wpdb->insert( $this->tableName(), [ 'created_at' => current_time('mysql', true), 'user_id' => get_current_user_id(), 'client' => $this->sanitizedClientName(), - 'ability' => mb_substr($ability, 0, 100), - 'object_type' => mb_substr($objectType, 0, 50), - 'object_id' => mb_substr($objectId, 0, 191), + 'ability' => $ability, + 'object_type' => $objectType, + 'object_id' => $objectId, 'summary' => mb_substr($summary, 0, 500), 'revision_id' => $revisionId, 'snapshot_id' => $snapshotId, @@ -59,12 +66,36 @@ class ChangeLog ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s'] ); + $logId = null; if ($inserted === false) { Log::line(sprintf('change log insert failed for %s %s:%s', $ability, $objectType, $objectId)); - return null; + } else { + $logId = (int) $wpdb->insert_id; } - return (int) $wpdb->insert_id; + if ($result !== 'refused') { + self::announce($ability, $objectType, $objectId, $logId); + } + + return $logId; + } + + /** + * do_action('lit_mcp_changed', ability, objectType, objectId, logId): a write happened (result ok or partial, + * never refused), even if its log row did not (logId null). + * + * ability, objectType and objectId are shortened like the log row (100/50/191 characters). objectId is an + * identifier or name from the tool input (post ID, option name, menu location ...): untrusted text, validate + * before use. A listener that throws is caught and logged, the caller must not see a failed write; exit and + * wp_die in a listener are not caught and end the request. + */ + private static function announce(string $ability, string $objectType, string $objectId, ?int $logId): void + { + try { + do_action(self::CHANGED_ACTION, $ability, $objectType, $objectId, $logId); + } catch (\Throwable $e) { + Log::failure(self::CHANGED_ACTION, $e); + } } /** diff --git a/tests/Unit/ChangeLog/ChangeLogTest.php b/tests/Unit/ChangeLog/ChangeLogTest.php index 2a40c13..16d6b48 100644 --- a/tests/Unit/ChangeLog/ChangeLogTest.php +++ b/tests/Unit/ChangeLog/ChangeLogTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\ChangeLog; +use Brain\Monkey\Actions; use Brain\Monkey\Functions; use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\Server\RequestContext; @@ -67,6 +68,106 @@ final class ChangeLogTest extends TestCase unlink($log); } + private function dbInserting(int|false $outcome): \wpdb + { + Functions\when('current_time')->justReturn('2026-09-25 08:00:00'); + Functions\when('get_current_user_id')->justReturn(3); + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->allows('insert')->andReturnUsing(static function () use ($db, $outcome): int|false { + $db->insert_id = 77; + + return $outcome; + }); + + return $db; + } + + public function testAnOkEntryAnnouncesTheChange(): void + { + $seen = []; + Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void { + $seen = $args; + }); + + (new ChangeLog($this->dbInserting(1)))->record('lit/content-update', 'page', '12', 'x'); + + self::assertSame(['lit/content-update', 'page', '12', 77], $seen); + } + + public function testARefusedEntryDoesNotAnnounceAChange(): void + { + Actions\expectDone('lit_mcp_changed')->never(); + + (new ChangeLog($this->dbInserting(1)))->record('lit/x', 'page', '1', 'x', result: 'refused'); + $this->addToAssertionCount(1); + } + + /** A partly executed change changed something: listeners (cache purge) must hear of it. */ + public function testAPartialEntryAnnouncesTheChange(): void + { + $seen = []; + Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void { + $seen = $args; + }); + + (new ChangeLog($this->dbInserting(1)))->record('lit/x', 'page', '1', 'x', result: 'partial'); + + self::assertSame(['lit/x', 'page', '1', 77], $seen); + } + + /** Listeners get the same shortened values as the log row (ability 100, type 50, id 191). */ + public function testTheAnnouncementCarriesTheShortenedValuesOfTheRow(): void + { + $seen = []; + Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void { + $seen = $args; + }); + + (new ChangeLog($this->dbInserting(1))) + ->record(str_repeat('a', 150), str_repeat('t', 80), str_repeat('ä', 300), 'x'); + + self::assertSame([str_repeat('a', 100), str_repeat('t', 50), str_repeat('ä', 191), 77], $seen); + } + + /** The write happened even when its log row did not: listeners (cache purge) still need to know. */ + public function testAFailedInsertStillAnnouncesTheChangeWithoutALogId(): void + { + $seen = []; + Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (...$args) use (&$seen): void { + $seen = $args; + }); + $previous = ini_set('error_log', (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log')); + + try { + $id = (new ChangeLog($this->dbInserting(false)))->record('lit/options', 'option', 'blogname', 'x'); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertNull($id); + self::assertSame(['lit/options', 'option', 'blogname', null], $seen); + } + + public function testAThrowingListenerChangesNothingAndIsLogged(): void + { + Actions\expectDone('lit_mcp_changed')->once()->whenHappen(static function (): void { + throw new \RuntimeException('listener broke'); + }); + $log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); + $previous = ini_set('error_log', $log); + + try { + $id = (new ChangeLog($this->dbInserting(1)))->record('lit/content-update', 'page', '12', 'x'); + } finally { + ini_set('error_log', (string) $previous); + } + + self::assertSame(77, $id); + self::assertStringContainsString('lit_mcp_changed failed', (string) file_get_contents($log)); + self::assertStringContainsString('listener broke', (string) file_get_contents($log)); + unlink($log); + } + public function testRecordTruncatesObjectIdAndAbilityToTheColumnSizes(): void { Functions\when('current_time')->justReturn('2026-09-25 08:00:00');