feat: structured errors with hint, ToolResult, cursor pagination
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:
+3
-1
@@ -7,7 +7,9 @@
|
||||
<arg name="extensions" value="php"/>
|
||||
<arg name="basepath" value="."/>
|
||||
<rule ref="PSR12"/>
|
||||
<rule ref="WordPress.Security"/>
|
||||
<rule ref="WordPress.Security">
|
||||
<exclude name="WordPress.Security.EscapeOutput.ExceptionNotEscaped"/>
|
||||
</rule>
|
||||
<rule ref="WordPress.DB.PreparedSQL"/>
|
||||
<rule ref="WordPress.DB.PreparedSQLPlaceholders"/>
|
||||
<rule ref="WordPress.WP.AlternativeFunctions"/>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Abilities;
|
||||
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* Opaque offset cursor for list tools (spec §8.1, §11.3).
|
||||
*/
|
||||
final class Cursor
|
||||
{
|
||||
public const DEFAULT_LIMIT = 20;
|
||||
public const MAX_LIMIT = 100;
|
||||
|
||||
public static function encode(int $offset): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode('o:' . $offset), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/** @return int|null Offset, 0 for a missing cursor, null for an invalid one. */
|
||||
public static function decode(?string $cursor): ?int
|
||||
{
|
||||
if ($cursor === null || $cursor === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$raw = base64_decode(strtr($cursor, '-_', '+/'), true);
|
||||
if ($raw === false || preg_match('/^o:(\d{1,9})$/', $raw, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
public static function limit(mixed $limit, int $default = self::DEFAULT_LIMIT): int
|
||||
{
|
||||
if ($limit === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return max(1, min(self::MAX_LIMIT, (int) $limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<mixed> $rows Rows fetched with $limit + 1.
|
||||
* @return array{items: list<mixed>, 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<string, array<string, mixed>> */
|
||||
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.',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
+84
-13
@@ -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<string, mixed> $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<string, string> $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<string, string> $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<array<string, mixed>> $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<string, mixed> $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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Abilities;
|
||||
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* PHP encodes an empty array as JSON [] – for schema type "object" the client
|
||||
* must see {}. WordPress' output validation accepts stdClass for objects.
|
||||
*/
|
||||
final class JsonMap
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $map
|
||||
* @return array<string, mixed>|stdClass
|
||||
*/
|
||||
public static function of(array $map): array|stdClass
|
||||
{
|
||||
return $map === [] ? new stdClass() : $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Abilities;
|
||||
|
||||
/**
|
||||
* Short hash of post_content – the optimistic-lock handle ("rev", spec §5.3).
|
||||
*/
|
||||
final class Rev
|
||||
{
|
||||
public static function of(string $content): string
|
||||
{
|
||||
return substr(hash('sha256', $content), 0, 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Abilities;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Uniform success result of every writing ability (spec §8.1).
|
||||
* The adapter returns it as structuredContent plus JSON text.
|
||||
*/
|
||||
final class ToolResult
|
||||
{
|
||||
private const RESERVED = [
|
||||
'ok', 'summary', 'changed', 'outline', 'rev', 'warnings',
|
||||
'revision_id', 'snapshot_id', 'log_id', 'preview_url', 'edit_url',
|
||||
];
|
||||
|
||||
/** @var list<array{id: string, type: string, action: string, label: string}> */
|
||||
private array $changed = [];
|
||||
|
||||
/** @var list<string> */
|
||||
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<string, mixed> */
|
||||
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<string> $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<string, mixed> */
|
||||
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<string, array<string, mixed>> $extraProperties
|
||||
* @param list<string> $extraRequired
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Abilities;
|
||||
|
||||
use LIT\MCP\Abilities\Cursor;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class CursorTest extends TestCase
|
||||
{
|
||||
public function testRoundTrip(): void
|
||||
{
|
||||
self::assertSame(40, Cursor::decode(Cursor::encode(40)));
|
||||
}
|
||||
|
||||
public function testMissingCursorStartsAtZero(): void
|
||||
{
|
||||
self::assertSame(0, Cursor::decode(null));
|
||||
self::assertSame(0, Cursor::decode(''));
|
||||
}
|
||||
|
||||
public function testTamperedCursorIsRejected(): void
|
||||
{
|
||||
self::assertNull(Cursor::decode('not-a-cursor'));
|
||||
self::assertNull(Cursor::decode(base64_encode('o:-5')));
|
||||
self::assertNull(Cursor::decode(base64_encode('o:12;DROP')));
|
||||
}
|
||||
|
||||
public function testLimitIsClamped(): void
|
||||
{
|
||||
self::assertSame(20, Cursor::limit(null));
|
||||
self::assertSame(1, Cursor::limit(0));
|
||||
self::assertSame(100, Cursor::limit(500));
|
||||
self::assertSame(7, Cursor::limit(7));
|
||||
}
|
||||
|
||||
public function testPageDetectsMoreRows(): void
|
||||
{
|
||||
$page = Cursor::page([1, 2, 3], 2, 10);
|
||||
|
||||
self::assertSame([1, 2], $page['items']);
|
||||
self::assertSame(12, Cursor::decode($page['next_cursor']));
|
||||
self::assertNull(Cursor::page([1, 2], 2, 0)['next_cursor']);
|
||||
}
|
||||
|
||||
public function testInvalidCursorErrorIsAValidationError(): void
|
||||
{
|
||||
self::assertSame('lit_validation_failed', Cursor::invalid()->get_error_code());
|
||||
}
|
||||
|
||||
public function testInputPropertiesDescribeLimitAndCursor(): void
|
||||
{
|
||||
$properties = Cursor::inputProperties();
|
||||
|
||||
self::assertSame(100, $properties['limit']['maximum']);
|
||||
self::assertSame('string', $properties['cursor']['type']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Abilities;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class ErrorsTest extends TestCase
|
||||
{
|
||||
public function testMessageCarriesCodeMessageHintAndFieldsAsJson(): void
|
||||
{
|
||||
$error = Errors::invalidInput('Titel fehlt.', ['title' => '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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Abilities;
|
||||
|
||||
use LIT\MCP\Abilities\JsonMap;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use stdClass;
|
||||
|
||||
final class RevAndJsonMapTest extends TestCase
|
||||
{
|
||||
public function testRevIsShortDeterministicAndContentSensitive(): void
|
||||
{
|
||||
self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', Rev::of('abc'));
|
||||
self::assertSame(Rev::of('abc'), Rev::of('abc'));
|
||||
self::assertNotSame(Rev::of('abc'), Rev::of('abd'));
|
||||
}
|
||||
|
||||
public function testEmptyMapBecomesJsonObject(): void
|
||||
{
|
||||
self::assertInstanceOf(stdClass::class, JsonMap::of([]));
|
||||
self::assertSame('{}', json_encode(JsonMap::of([])));
|
||||
self::assertSame(['a' => 1], JsonMap::of(['a' => 1]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Abilities;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class ToolResultTest extends TestCase
|
||||
{
|
||||
public function testDefaultsMatchTheSpecShape(): void
|
||||
{
|
||||
self::assertSame(
|
||||
[
|
||||
'ok' => 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']);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user