Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Model/JsonRpc/Handler/ToolsListHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public function handle(Request $request, AuthenticatedContext $context): Respons
'name' => str_replace('.', '_', $tool->getName()),
'title' => $displayTitle,
'description' => $tool->getDescription(),
'inputSchema' => $this->schemaSanitizer->sanitize(
'inputSchema' => $this->schemaSanitizer->sanitizeForClient(
$tool->getName(),
$tool->getInputSchema()
),
Expand Down
64 changes: 61 additions & 3 deletions Model/Tool/SchemaSanitizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,33 @@ public function __construct(
public function sanitize(string $toolName, array $schema): array
{
/** @var array<string, mixed> $walked */
$walked = $this->walk($toolName, $schema, '');
$walked = $this->walk($toolName, $schema, '', false);
return $walked;
}

/**
* Same as {@see sanitize()} plus the rewrites clients need. Only for what
* `tools/list` advertises — argument validation keeps the stricter schema.
*
* @param string $toolName
* @param array<string, mixed> $schema
* @return array<string, mixed>
*/
public function sanitizeForClient(string $toolName, array $schema): array
{
/** @var array<string, mixed> $walked */
$walked = $this->walk($toolName, $schema, '', true);
return $walked;
}

/**
* @param string $toolName
* @param mixed $node
* @param string $path
* @param bool $forClient
* @return mixed
*/
private function walk(string $toolName, mixed $node, string $path): mixed
private function walk(string $toolName, mixed $node, string $path, bool $forClient): mixed
{
if (!is_array($node)) {
return $node;
Expand All @@ -68,15 +84,57 @@ private function walk(string $toolName, mixed $node, string $path): mixed
}
}

if ($forClient) {
$node = $this->foldNonStringEnum($node);
}

$cleaned = [];
foreach ($node as $key => $value) {
$childPath = $path === '' ? (string) $key : $path . '.' . $key;
if ($key === 'properties' && is_array($value) && $value === []) {
$cleaned[$key] = new stdClass();
continue;
}
$cleaned[$key] = $this->walk($toolName, $value, $childPath);
$cleaned[$key] = $this->walk($toolName, $value, $childPath, $forClient);
}
return $cleaned;
}

/**
* Replaces an `enum` holding non-string values with a description of them.
*
* @param array<mixed> $node
* @return array<mixed>
*/
private function foldNonStringEnum(array $node): array
{
$values = $node['enum'] ?? null;
if (!is_array($values) || !array_is_list($values) || $values === []) {
return $node;
}
foreach ($values as $value) {
if (!is_string($value)) {
unset($node['enum']);
$node['description'] = $this->describeValues($node['description'] ?? null, $values);
return $node;
}
}
return $node;
}

/**
* @param mixed $description
* @param array<int, mixed> $values
* @return string
*/
private function describeValues(mixed $description, array $values): string
{
$encoded = array_map(
static fn (mixed $value): string => (string) json_encode($value, JSON_UNESCAPED_SLASHES),
$values
);
$sentence = sprintf('Allowed values: %s.', implode(', ', $encoded));
$existing = is_string($description) ? trim($description) : '';
return $existing === '' ? $sentence : $existing . ' ' . $sentence;
}
}
173 changes: 173 additions & 0 deletions Test/Unit/Model/Tool/SchemaSanitizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,177 @@ public function testEmptyPropertiesNormalizedAtNestedDepth(): void
self::assertIsArray($address);
self::assertInstanceOf(\stdClass::class, $address['properties']);
}

public function testDropsIntegerEnumAndFoldsValuesIntoDescription(): void
{
$schema = [
'type' => 'object',
'properties' => [
'status' => [
'type' => 'integer',
'enum' => [1, 2],
'description' => 'Product status.',
],
],
'required' => ['status'],
];

$expected = [
'type' => 'object',
'properties' => [
'status' => [
'type' => 'integer',
'description' => 'Product status. Allowed values: 1, 2.',
],
],
'required' => ['status'],
];

self::assertSame($expected, $this->sanitizer->sanitizeForClient('test.tool', $schema));
}

public function testValidationSchemaKeepsIntegerEnum(): void
{
$schema = [
'type' => 'object',
'properties' => [
'status' => ['type' => 'integer', 'enum' => [1, 2]],
],
];

self::assertSame($schema, $this->sanitizer->sanitize('test.tool', $schema));
}

public function testDroppedEnumWithoutDescriptionGetsOne(): void
{
$schema = [
'type' => 'object',
'properties' => [
'visibility' => ['type' => 'integer', 'enum' => [1, 2, 3, 4]],
],
];

$expected = [
'type' => 'object',
'properties' => [
'visibility' => [
'type' => 'integer',
'description' => 'Allowed values: 1, 2, 3, 4.',
],
],
];

self::assertSame($expected, $this->sanitizer->sanitizeForClient('test.tool', $schema));
}

public function testKeepsStringEnumUntouched(): void
{
$schema = [
'type' => 'object',
'properties' => [
'sort_dir' => [
'type' => 'string',
'enum' => ['asc', 'desc'],
'description' => 'Sort direction.',
],
],
];

self::assertSame($schema, $this->sanitizer->sanitizeForClient('test.tool', $schema));
}

public function testDropsEnumNestedInsideArrayItems(): void
{
$schema = [
'type' => 'object',
'properties' => [
'items' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'sku' => ['type' => 'string'],
'backorders' => [
'type' => 'integer',
'enum' => [0, 1, 2],
'description' => '0 = no, 1 = allow.',
],
],
'required' => ['sku'],
],
],
],
'required' => ['items'],
];

$expected = [
'type' => 'object',
'properties' => [
'items' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'sku' => ['type' => 'string'],
'backorders' => [
'type' => 'integer',
'description' => '0 = no, 1 = allow. Allowed values: 0, 1, 2.',
],
],
'required' => ['sku'],
],
],
],
'required' => ['items'],
];

self::assertSame($expected, $this->sanitizer->sanitizeForClient('test.tool', $schema));
}

public function testDropsEnumMixingStringsAndNumbers(): void
{
$schema = [
'type' => 'object',
'properties' => [
'mode' => ['type' => 'string', 'enum' => ['all', 0]],
],
];

$expected = [
'type' => 'object',
'properties' => [
'mode' => [
'type' => 'string',
'description' => 'Allowed values: "all", 0.',
],
],
];

self::assertSame($expected, $this->sanitizer->sanitizeForClient('test.tool', $schema));
}

public function testLeavesPropertyNamedEnumAlone(): void
{
$schema = [
'type' => 'object',
'properties' => [
'enum' => ['type' => 'string', 'description' => 'A field named enum.'],
],
'required' => ['enum'],
];

self::assertSame($schema, $this->sanitizer->sanitizeForClient('test.tool', $schema));
}

public function testLeavesPropertyNamedEnumWithNumericKeywordsAlone(): void
{
$schema = [
'type' => 'object',
'properties' => [
'enum' => ['type' => 'integer', 'minimum' => 1],
],
];

self::assertSame($schema, $this->sanitizer->sanitizeForClient('test.tool', $schema));
}
}
Loading