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