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:
2026-09-25 21:37:42 +02:00
co-authored by Claude Opus 5.5
parent 5434cb6767
commit f63d35c2f8
12 changed files with 631 additions and 14 deletions
+83
View File
@@ -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
View File
@@ -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
);
}
}
+23
View File
@@ -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;
}
}
+16
View File
@@ -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);
}
}
+188
View File
@@ -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
),
];
}
}