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:
@@ -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.',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user