From f63d35c2f87539305dc2c8faaf06d24834cdfd74 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 25 Sep 2026 21:37:05 +0200 Subject: [PATCH] feat: structured errors with hint, ToolResult, cursor pagination Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- phpcs.xml.dist | 4 +- src/Abilities/Cursor.php | 83 +++++++++ src/Abilities/Errors.php | 97 +++++++++-- src/Abilities/JsonMap.php | 23 +++ src/Abilities/Rev.php | 16 ++ src/Abilities/ToolResult.php | 188 +++++++++++++++++++++ tests/Unit/Abilities/CursorTest.php | 59 +++++++ tests/Unit/Abilities/ErrorsTest.php | 58 +++++++ tests/Unit/Abilities/RevAndJsonMapTest.php | 27 +++ tests/Unit/Abilities/ToolResultTest.php | 76 +++++++++ tests/Unit/TestCase.php | 13 ++ wp-lit-mcp.php | 1 + 12 files changed, 631 insertions(+), 14 deletions(-) create mode 100644 src/Abilities/Cursor.php create mode 100644 src/Abilities/JsonMap.php create mode 100644 src/Abilities/Rev.php create mode 100644 src/Abilities/ToolResult.php create mode 100644 tests/Unit/Abilities/CursorTest.php create mode 100644 tests/Unit/Abilities/ErrorsTest.php create mode 100644 tests/Unit/Abilities/RevAndJsonMapTest.php create mode 100644 tests/Unit/Abilities/ToolResultTest.php diff --git a/phpcs.xml.dist b/phpcs.xml.dist index a792708..3ca00dd 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -7,7 +7,9 @@ - + + + diff --git a/src/Abilities/Cursor.php b/src/Abilities/Cursor.php new file mode 100644 index 0000000..e60c645 --- /dev/null +++ b/src/Abilities/Cursor.php @@ -0,0 +1,83 @@ + $rows Rows fetched with $limit + 1. + * @return array{items: list, next_cursor: ?string} + */ + public static function page(array $rows, int $limit, int $offset): array + { + return [ + 'items' => array_slice($rows, 0, $limit), + 'next_cursor' => count($rows) > $limit ? self::encode($offset + $limit) : null, + ]; + } + + public static function invalid(): WP_Error + { + return Errors::invalidInput( + 'Ungültiger cursor.', + ['cursor' => 'unbekanntes Format'], + 'cursor unverändert aus next_cursor der letzten Antwort übernehmen oder weglassen.' + ); + } + + /** @return array> */ + public static function inputProperties(): array + { + return [ + 'limit' => [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => self::MAX_LIMIT, + 'description' => 'Einträge pro Seite (Standard 20).', + ], + 'cursor' => [ + 'type' => 'string', + 'description' => 'next_cursor der vorherigen Antwort, um weiterzublättern.', + ], + ]; + } +} diff --git a/src/Abilities/Errors.php b/src/Abilities/Errors.php index 3d59620..cb722cf 100644 --- a/src/Abilities/Errors.php +++ b/src/Abilities/Errors.php @@ -7,33 +7,104 @@ namespace LIT\MCP\Abilities; use WP_Error; /** - * Factory for the error objects abilities return. The MCP adapter turns them - * into tool results with isError: true. + * Factory for the error objects abilities return. + * + * mcp-adapter 0.5 turns a WP_Error into a tool result with isError: true but + * forwards only the message text. The message therefore carries the whole + * structured error as JSON: {code, message, hint, fields?, usages?, …} (spec §8.2). */ final class Errors { - public static function forbidden(string $message): WP_Error + /** + * @param array $details Extra keys for the client, e.g. fields or usages. + */ + public static function make(string $code, string $message, string $hint, int $status, array $details = []): WP_Error { - return new WP_Error('lit_forbidden', $message, ['status' => 403]); + $payload = array_merge(['code' => $code, 'message' => $message, 'hint' => $hint], $details); + $json = wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return new WP_Error( + 'lit_' . $code, + is_string($json) ? $json : $message, + array_merge(['status' => $status, 'hint' => $hint], $details) + ); } - /** @param array $fields */ - public static function invalidInput(string $message, array $fields = []): WP_Error - { - return new WP_Error('lit_validation_failed', $message, ['status' => 400, 'fields' => $fields]); + public static function forbidden( + string $message, + string $hint = 'Mit den Rechten des verbundenen WordPress-Kontos nicht erlaubt; ggf. Administrator fragen.' + ): WP_Error { + return self::make('forbidden', $message, $hint, 403); } - public static function notFound(string $message): WP_Error + /** + * @param array $fields Input path => problem. + */ + public static function invalidInput( + string $message, + array $fields = [], + string $hint = 'Die unter fields genannten Eingaben korrigieren und erneut senden.' + ): WP_Error { + return self::make('validation_failed', $message, $hint, 400, $fields === [] ? [] : ['fields' => $fields]); + } + + public static function notFound( + string $message, + string $hint = 'ID prüfen, z. B. über lit-content-query oder lit-media-search suchen.' + ): WP_Error { + return self::make('not_found', $message, $hint, 404); + } + + public static function revMismatch(): WP_Error { - return new WP_Error('lit_not_found', $message, ['status' => 404]); + return self::make( + 'rev_mismatch', + 'Der Inhalt wurde seit dem letzten Lesen geändert; nichts wurde geschrieben.', + 'Neu lesen (z. B. lit-content-get) und die Änderung auf dem aktuellen Stand wiederholen.', + 409 + ); + } + + /** + * @param list> $usages + */ + public static function inUse(string $message, array $usages): WP_Error + { + return self::make( + 'in_use', + $message, + 'Verwendungen prüfen; nur mit force: true trotzdem ausführen.', + 409, + ['usages' => $usages] + ); + } + + /** + * @param array $details + */ + public static function confirmRequired(string $message, array $details = []): WP_Error + { + return self::make( + 'confirm_required', + $message, + 'Beim Nutzer nachfragen und dann mit confirm: true erneut aufrufen.', + 409, + $details + ); + } + + public static function unsupported(string $message, string $hint): WP_Error + { + return self::make('unsupported', $message, $hint, 400); } public static function internal(): WP_Error { - return new WP_Error( - 'lit_internal_error', + return self::make( + 'internal_error', 'Interner Fehler bei der Ausführung. Details stehen im PHP-Error-Log der Website.', - ['status' => 500] + 'Später erneut versuchen oder den Administrator der Website informieren.', + 500 ); } } diff --git a/src/Abilities/JsonMap.php b/src/Abilities/JsonMap.php new file mode 100644 index 0000000..52ecef8 --- /dev/null +++ b/src/Abilities/JsonMap.php @@ -0,0 +1,23 @@ + $map + * @return array|stdClass + */ + public static function of(array $map): array|stdClass + { + return $map === [] ? new stdClass() : $map; + } +} diff --git a/src/Abilities/Rev.php b/src/Abilities/Rev.php new file mode 100644 index 0000000..657e447 --- /dev/null +++ b/src/Abilities/Rev.php @@ -0,0 +1,16 @@ + */ + private array $changed = []; + + /** @var list */ + private array $warnings = []; + + private ?string $outline = null; + private ?string $rev = null; + private ?int $revisionId = null; + private ?string $snapshotId = null; + private ?int $logId = null; + private ?string $previewUrl = null; + private ?string $editUrl = null; + + /** @var array */ + private array $extra = []; + + private function __construct(private string $summary) + { + } + + public static function ok(string $summary): self + { + return new self($summary); + } + + public function changed(string $id, string $type, string $action, string $label = ''): self + { + $this->changed[] = ['id' => $id, 'type' => $type, 'action' => $action, 'label' => $label]; + + return $this; + } + + public function warning(string $warning): self + { + $this->warnings[] = $warning; + + return $this; + } + + /** @param list $warnings */ + public function warnings(array $warnings): self + { + foreach ($warnings as $warning) { + $this->warning($warning); + } + + return $this; + } + + public function outline(?string $outline): self + { + $this->outline = $outline; + + return $this; + } + + public function rev(?string $rev): self + { + $this->rev = $rev; + + return $this; + } + + public function revisionId(?int $revisionId): self + { + $this->revisionId = $revisionId; + + return $this; + } + + public function snapshotId(?string $snapshotId): self + { + $this->snapshotId = $snapshotId; + + return $this; + } + + public function logId(?int $logId): self + { + $this->logId = $logId; + + return $this; + } + + public function urls(?string $previewUrl, ?string $editUrl): self + { + $this->previewUrl = $previewUrl === '' ? null : $previewUrl; + $this->editUrl = $editUrl === '' ? null : $editUrl; + + return $this; + } + + public function with(string $key, mixed $value): self + { + if (in_array($key, self::RESERVED, true)) { + throw new InvalidArgumentException(sprintf('ToolResult key "%s" is reserved.', $key)); + } + + $this->extra[$key] = $value; + + return $this; + } + + /** @return array */ + public function toArray(): array + { + return array_merge( + [ + 'ok' => true, + 'summary' => $this->summary, + 'changed' => $this->changed, + 'outline' => $this->outline, + 'rev' => $this->rev, + 'warnings' => $this->warnings, + 'revision_id' => $this->revisionId, + 'snapshot_id' => $this->snapshotId, + 'log_id' => $this->logId, + 'preview_url' => $this->previewUrl, + 'edit_url' => $this->editUrl, + ], + $this->extra + ); + } + + /** + * @param array> $extraProperties + * @param list $extraRequired + * @return array + */ + public static function schema(array $extraProperties = [], array $extraRequired = []): array + { + return [ + 'type' => 'object', + 'properties' => array_merge( + [ + 'ok' => ['type' => 'boolean'], + 'summary' => ['type' => 'string'], + 'changed' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'string'], + 'type' => ['type' => 'string'], + 'action' => ['type' => 'string'], + 'label' => ['type' => 'string'], + ], + 'required' => ['id', 'type', 'action', 'label'], + ], + ], + 'outline' => ['type' => ['string', 'null']], + 'rev' => ['type' => ['string', 'null']], + 'warnings' => ['type' => 'array', 'items' => ['type' => 'string']], + 'revision_id' => ['type' => ['integer', 'null']], + 'snapshot_id' => ['type' => ['string', 'null']], + 'log_id' => ['type' => ['integer', 'null']], + 'preview_url' => ['type' => ['string', 'null']], + 'edit_url' => ['type' => ['string', 'null']], + ], + $extraProperties + ), + 'required' => array_merge( + ['ok', 'summary', 'changed', 'warnings', 'rev', 'revision_id', 'log_id'], + $extraRequired + ), + ]; + } +} diff --git a/tests/Unit/Abilities/CursorTest.php b/tests/Unit/Abilities/CursorTest.php new file mode 100644 index 0000000..1b819e8 --- /dev/null +++ b/tests/Unit/Abilities/CursorTest.php @@ -0,0 +1,59 @@ +get_error_code()); + } + + public function testInputPropertiesDescribeLimitAndCursor(): void + { + $properties = Cursor::inputProperties(); + + self::assertSame(100, $properties['limit']['maximum']); + self::assertSame('string', $properties['cursor']['type']); + } +} diff --git a/tests/Unit/Abilities/ErrorsTest.php b/tests/Unit/Abilities/ErrorsTest.php new file mode 100644 index 0000000..0012c02 --- /dev/null +++ b/tests/Unit/Abilities/ErrorsTest.php @@ -0,0 +1,58 @@ + 'Pflichtfeld']); + + self::assertSame('lit_validation_failed', $error->get_error_code()); + $payload = json_decode($error->get_error_message(), true); + self::assertSame('validation_failed', $payload['code']); + self::assertSame('Titel fehlt.', $payload['message']); + self::assertNotSame('', $payload['hint']); + self::assertSame(['title' => 'Pflichtfeld'], $payload['fields']); + self::assertSame(400, $error->get_error_data()['status']); + self::assertSame($payload['hint'], $error->get_error_data()['hint']); + } + + public function testUmlautsStayReadableInTheMessage(): void + { + $error = Errors::forbidden('Dein Benutzerkonto ist für LIT MCP nicht freigegeben.'); + + self::assertStringContainsString('für LIT MCP nicht freigegeben', $error->get_error_message()); + } + + public function testInUseCarriesTheUsages(): void + { + $error = Errors::inUse('Bild wird verwendet.', [['post_id' => 7, 'where' => 'featured']]); + + $payload = json_decode($error->get_error_message(), true); + self::assertSame('lit_in_use', $error->get_error_code()); + self::assertSame(7, $payload['usages'][0]['post_id']); + } + + public function testEveryFactoryUsesLitPrefixAndANonEmptyHint(): void + { + $errors = [ + Errors::forbidden('x'), + Errors::notFound('x'), + Errors::revMismatch(), + Errors::confirmRequired('x'), + Errors::unsupported('x', 'y'), + Errors::internal(), + ]; + + foreach ($errors as $error) { + self::assertStringStartsWith('lit_', $error->get_error_code()); + self::assertNotSame('', json_decode($error->get_error_message(), true)['hint']); + } + } +} diff --git a/tests/Unit/Abilities/RevAndJsonMapTest.php b/tests/Unit/Abilities/RevAndJsonMapTest.php new file mode 100644 index 0000000..2141af7 --- /dev/null +++ b/tests/Unit/Abilities/RevAndJsonMapTest.php @@ -0,0 +1,27 @@ + 1], JsonMap::of(['a' => 1])); + } +} diff --git a/tests/Unit/Abilities/ToolResultTest.php b/tests/Unit/Abilities/ToolResultTest.php new file mode 100644 index 0000000..4022baa --- /dev/null +++ b/tests/Unit/Abilities/ToolResultTest.php @@ -0,0 +1,76 @@ + true, + 'summary' => 'Seite angelegt', + 'changed' => [], + 'outline' => null, + 'rev' => null, + 'warnings' => [], + 'revision_id' => null, + 'snapshot_id' => null, + 'log_id' => null, + 'preview_url' => null, + 'edit_url' => null, + ], + ToolResult::ok('Seite angelegt')->toArray() + ); + } + + public function testBuilderFillsAllFieldsAndExtras(): void + { + $result = ToolResult::ok('Aktualisiert') + ->changed('12', 'page', 'updated', 'Startseite') + ->warning('Bild ohne Alt-Text') + ->warnings(['Zweite Warnung']) + ->outline('Hero') + ->rev('a1b2c3d4e5f6') + ->revisionId(812) + ->snapshotId('snap-1') + ->logId(4711) + ->urls('https://kunde.de/?p=12&preview=true', 'https://kunde.de/wp-admin/post.php?post=12&action=edit') + ->with('previous', ['blogname' => 'Alt']) + ->toArray(); + + self::assertSame([['id' => '12', 'type' => 'page', 'action' => 'updated', 'label' => 'Startseite']], $result['changed']); + self::assertSame(['Bild ohne Alt-Text', 'Zweite Warnung'], $result['warnings']); + self::assertSame('Hero', $result['outline']); + self::assertSame('a1b2c3d4e5f6', $result['rev']); + self::assertSame(812, $result['revision_id']); + self::assertSame('snap-1', $result['snapshot_id']); + self::assertSame(4711, $result['log_id']); + self::assertSame('https://kunde.de/?p=12&preview=true', $result['preview_url']); + self::assertSame(['blogname' => 'Alt'], $result['previous']); + } + + public function testReservedKeysCannotBeOverwrittenByExtras(): void + { + $this->expectException(InvalidArgumentException::class); + + ToolResult::ok('x')->with('ok', false); + } + + public function testSchemaRequiresCoreKeysAndAcceptsExtraProperties(): void + { + $schema = ToolResult::schema(['previous' => ['type' => 'object']], ['previous']); + + self::assertSame('object', $schema['type']); + self::assertContains('summary', $schema['required']); + self::assertContains('previous', $schema['required']); + self::assertSame(['integer', 'null'], $schema['properties']['revision_id']['type']); + self::assertSame(['type' => 'object'], $schema['properties']['previous']); + } +} diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index 3dc5f11..28ea60c 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit; use Brain\Monkey; +use Brain\Monkey\Functions; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use PHPUnit\Framework\TestCase as BaseTestCase; @@ -16,6 +17,18 @@ abstract class TestCase extends BaseTestCase { parent::setUp(); Monkey\setUp(); + + // Pure helpers with obvious behaviour. Never use Functions\expect() on these + // names in a test: Brain Monkey forbids a stub and an expectation for the + // same function. Functions\when() may still redefine them. + Functions\stubs([ + 'wp_json_encode' => static fn ($data, int $flags = 0) => json_encode($data, $flags), + 'is_wp_error' => static fn ($thing): bool => $thing instanceof \WP_Error, + 'absint' => static fn ($value): int => abs((int) $value), + 'wp_slash' => static fn ($value) => $value, + 'wp_unslash' => static fn ($value) => $value, + 'sanitize_text_field' => static fn ($value): string => trim(strip_tags((string) $value)), + ]); } protected function tearDown(): void diff --git a/wp-lit-mcp.php b/wp-lit-mcp.php index c089e3b..da14209 100644 --- a/wp-lit-mcp.php +++ b/wp-lit-mcp.php @@ -6,6 +6,7 @@ * Version: 0.1.0 * Requires at least: 6.9 * Requires PHP: 8.1 + * Tested up to: 7.0 * Author: Jan-Niclas Loosen - Loosen-IT * Author URI: https://loosen-it.de * License: GPL-2.0-or-later