feat(changelog): Hook lit_mcp_changed

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 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 14:50:12 +01:00
co-authored by Claude Opus 5.5
parent 1a294b7881
commit c383b99e7d
2 changed files with 137 additions and 5 deletions
+36 -5
View File
@@ -18,6 +18,9 @@ class ChangeLog
{ {
public const TABLE = 'lit_mcp_log'; 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) public function __construct(private ?\wpdb $db = null)
{ {
} }
@@ -42,15 +45,19 @@ class ChangeLog
string $result = 'ok' string $result = 'ok'
): ?int { ): ?int {
$wpdb = $this->db(); $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( $inserted = $wpdb->insert(
$this->tableName(), $this->tableName(),
[ [
'created_at' => current_time('mysql', true), 'created_at' => current_time('mysql', true),
'user_id' => get_current_user_id(), 'user_id' => get_current_user_id(),
'client' => $this->sanitizedClientName(), 'client' => $this->sanitizedClientName(),
'ability' => mb_substr($ability, 0, 100), 'ability' => $ability,
'object_type' => mb_substr($objectType, 0, 50), 'object_type' => $objectType,
'object_id' => mb_substr($objectId, 0, 191), 'object_id' => $objectId,
'summary' => mb_substr($summary, 0, 500), 'summary' => mb_substr($summary, 0, 500),
'revision_id' => $revisionId, 'revision_id' => $revisionId,
'snapshot_id' => $snapshotId, 'snapshot_id' => $snapshotId,
@@ -59,12 +66,36 @@ class ChangeLog
['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s'] ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s']
); );
$logId = null;
if ($inserted === false) { if ($inserted === false) {
Log::line(sprintf('change log insert failed for %s %s:%s', $ability, $objectType, $objectId)); 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);
}
} }
/** /**
+101
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\ChangeLog; namespace LIT\MCP\Tests\Unit\ChangeLog;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\ChangeLog\ChangeLog; use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Server\RequestContext; use LIT\MCP\Server\RequestContext;
@@ -67,6 +68,106 @@ final class ChangeLogTest extends TestCase
unlink($log); 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 public function testRecordTruncatesObjectIdAndAbilityToTheColumnSizes(): void
{ {
Functions\when('current_time')->justReturn('2026-09-25 08:00:00'); Functions\when('current_time')->justReturn('2026-09-25 08:00:00');