Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
* [BC Break] `DocBlockParser::parseDocBlock()` no longer accepts `null`; pass `ReflectionMethod::getDocComment()` as is
* [BC Break] Fix a `DELETE` of an unknown or ended session being answered `200` instead of the `404` the spec requires: `Protocol::destroySession()` and `BaseTransport::handleSessionEnd()` return `false` for such a session, as does the listener of `TransportInterface::onSessionEnd()`. A listener that returns nothing keeps the `200`.
* Fix tools with an array shape parameter whose keys are nullable or unions (e.g. `array{name: string, nickname: string|null}`) rejecting valid arguments with `-32602`: a `null` inside the shape no longer turns the parameter into `array|null`, and such keys get their own types instead of `object`, including lists and nested shapes (`string[]|null`, `array{x: int}|null`).
* Fix tools whose DocBlock uses a PHPStan/Psalm pseudo-type or a literal type (`non-empty-string`, `class-string`, `positive-int`, `array-key`, `'asc'|'desc'`, `non-empty-list<T>`, ...) rejecting valid arguments with `-32602`: such parameters, their nullable form (`?non-empty-string`) included, and such keys of an array shape, get the JSON Schema type of their base type instead of `object`. Inside a shape, `list<T>` and `array<int, T>` keys become arrays, `array<string, T>` keys become objects, `array<array-key, T>` keys accept either and `mixed` keys accept any value.
* Fix `array<string|null>` and other generics with a union element getting `items` of type `object`. A `null` inside a generic no longer makes the parameter nullable, and neither does a DocBlock class whose name contains `null`.
* Fix `InMemorySessionStore::exists()` reporting a session past its TTL as existing, while `read()` already treated it as gone.
* Fix `Psr16NotificationBus` subscribers missing the notifications published after the bus stayed quiet for longer than its TTL: the sequence counter no longer expires, and a subscriber whose cursor is ahead of a lost counter reads what the bus holds.
Expand Down
119 changes: 102 additions & 17 deletions src/Capability/Discovery/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,10 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array
{
$normalizedType = strtolower(trim($phpTypeString));

if (str_starts_with($normalizedType, '?')) {
$normalizedType = 'null|'.substr($normalizedType, 1);
}

// PRIORITY 1: Handle unions before anything else. A `|` at the top
// level joins alternatives; one inside `<>` or `{}` belongs to a
// generic (`array<int|string>`) and is not a union of the whole type.
Expand All @@ -773,33 +777,34 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array
return ['object'];
}

// A non-empty map with string keys is a JSON object; it cannot be the `[]` that an empty `{}` decodes to.
if (preg_match('/^non-empty-array\s*</', $normalizedType)) {
return (array) $this->mapTypeToSchema($normalizedType)['type'];
}

// PRIORITY 3: Check for array syntax (T[] or generics)
if (
str_contains($normalizedType, '[]')
|| preg_match('/^(array|list|iterable|collection)</i', $normalizedType)
|| preg_match('/^(array|list|non-empty-list|iterable|collection)</i', $normalizedType)
|| \in_array($normalizedType, ['list', 'non-empty-array', 'non-empty-list'], true)
) {
return ['array'];
}

// PRIORITY 4: Treat PHPStan/Psalm integer ranges as their base type
if (preg_match('/^int\s*<\s*[^<>]+\s*>$/i', $normalizedType)) {
return ['integer'];
// PRIORITY 4: Treat PHPStan/Psalm integer ranges and other pseudo-types
// (`non-empty-string`, `positive-int`, literals, ...) as their base type
if (null !== $pseudoTypes = self::mapPseudoTypeToJsonSchemaTypes($normalizedType)) {
return $pseudoTypes;
}

// PRIORITY 5: Handle simple built-in types
return match ($normalizedType) {
'string', 'scalar' => ['string'],
'?string' => ['null', 'string'],
'int', 'integer' => ['integer'],
'?int', '?integer' => ['null', 'integer'],
'float', 'double', 'number' => ['number'],
'?float', '?double', '?number' => ['null', 'number'],
'bool', 'boolean' => ['boolean'],
'?bool', '?boolean' => ['null', 'boolean'],
'array' => ['array'],
'?array' => ['null', 'array'],
'object', 'stdclass' => ['object'],
'?object', '?stdclass' => ['null', 'object'],
'null' => ['null'],
'resource', 'callable' => ['object'],
'mixed' => [],
Expand All @@ -808,6 +813,49 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array
};
}

/**
* Maps a PHPStan/Psalm pseudo-type or a literal type to the JSON Schema
* types of its base type, e.g. `non-empty-string` to `string`,
* `positive-int` or `int<0, max>` to `integer` and `'a'` to `string`.
*
* @return list<string>|null null when the type is not a known pseudo-type
*/
private static function mapPseudoTypeToJsonSchemaTypes(string $type): ?array
{
$type = strtolower(trim($type));

if (preg_match('/^int\s*<\s*[^<>]+\s*>$/', $type)) {
return ['integer'];
}

if (preg_match('/^class-string\s*<[^<>]+>$/', $type)) {
return ['string'];
}

// Literal types: 'foo', "foo", 42, -1.5
if (preg_match('/^(\'[^\']*\'|"[^"]*")$/', $type)) {
return ['string'];
Comment on lines +836 to +837
}
if (preg_match('/^-?\d+$/', $type)) {
return ['integer'];
}
if (preg_match('/^-?\d*\.\d+$/', $type)) {
return ['number'];
}

return match ($type) {
'non-empty-string', 'non-falsy-string', 'truthy-string', 'numeric-string', 'literal-string',
'non-empty-literal-string', 'lowercase-string', 'non-empty-lowercase-string', 'uppercase-string',
'non-empty-uppercase-string', 'class-string', 'interface-string', 'trait-string', 'enum-string',
'callable-string' => ['string'],
'positive-int', 'negative-int', 'non-positive-int', 'non-negative-int', 'non-zero-int' => ['integer'],
'true', 'false' => ['boolean'],
'array-key' => ['integer', 'string'],
'numeric' => ['integer', 'number', 'string'],
default => null,
};
}

/**
* Infers the 'items' schema type for an array based on DocBlock type hints.
*
Expand All @@ -830,8 +878,11 @@ private function inferArrayItemsType(string $phpTypeString): string|array
}

// Case 2: Generic array<T> syntax (e.g., array<string>, array<int>, etc.)
if (preg_match('/^(\\??)array\s*<\s*([\w\\\\|?]+)\s*>$/i', $normalizedType, $matches)) {
if (preg_match('/^(\\??)(?:array|list|non-empty-array|non-empty-list)\s*<\s*([\w\\\\|?\'"-]+)\s*>$/i', $normalizedType, $matches)) {
$itemSchema = $this->mapTypeToSchema(strtolower($matches[2]));
if (!isset($itemSchema['type'])) {
return 'any';
}
Comment on lines +883 to +885

return \is_string($itemSchema['type']) && 1 === \count($itemSchema) ? $itemSchema['type'] : $itemSchema;
}
Expand Down Expand Up @@ -877,11 +928,11 @@ private function parseObjectLikeArray(string $propertiesStr): array
for ($i = 0; $i < \strlen($propertiesStr); ++$i) {
$char = $propertiesStr[$i];

// Track nested braces
if ('{' === $char) {
// Track nested braces and generics, so the comma of `array<int, string>` stays in its key
if ('{' === $char || '<' === $char) {
++$depth;
$buffer .= $char;
} elseif ('}' === $char) {
} elseif ('}' === $char || '>' === $char) {
Comment on lines +932 to +935
--$depth;
$buffer .= $char;
}
Expand Down Expand Up @@ -924,7 +975,8 @@ private function parsePropertyDefinition(string $propDefinition, array &$propert
// Add to required properties
$required[] = $propName;

$properties[$propName] = $this->mapTypeToSchema($propType);
// `mixed` accepts anything, which an empty schema expresses; it must encode as `{}`, not `[]`.
$properties[$propName] = $this->mapTypeToSchema($propType) ?: new \stdClass();
}
}

Expand All @@ -943,6 +995,12 @@ private function mapTypeToSchema(string $type): array
if (\count($branches) > 1) {
$nonNull = array_values(array_filter($branches, static fn (string $branch): bool => 'null' !== strtolower($branch)));
$schemas = array_map($this->mapTypeToSchema(...), $nonNull);
// A branch without a type (`mixed`) accepts anything, and so does the union.
foreach ($schemas as $branchSchema) {
if (!isset($branchSchema['type'])) {
return [];
}
}
$types = array_values(array_unique(array_merge(...array_map(static fn (array $schema): array => (array) $schema['type'], $schemas))));
if (\count($nonNull) < \count($branches)) {
$types[] = 'null';
Expand All @@ -959,11 +1017,38 @@ private function mapTypeToSchema(string $type): array
return $this->parseObjectLikeArray($matches[1]);
}

// `array<K, V>`: the key type decides between a JSON array, a JSON object or either
if (preg_match('/^(?:array|list|non-empty-array|non-empty-list)\s*<\s*((?:[^<>{},]|<[^<>]*>)+?)\s*,\s*(.+?)\s*>$/is', $type, $matches)) {
$keyTypes = $this->mapPhpTypeToJsonSchemaType($matches[1]);
$valueSchema = $this->mapTypeToSchema($matches[2]);
$items = ['items' => $valueSchema ?: new \stdClass()];
$additionalProperties = ['additionalProperties' => $valueSchema ?: true];

return match ($keyTypes) {
['integer'] => ['type' => 'array'] + $items,
['string'] => ['type' => 'object'] + $additionalProperties,
default => ['type' => ['array', 'object']] + $items + $additionalProperties,
};
}

// `array<T>`, `list<T>`, `non-empty-list<T>`, `non-empty-array<T>` and `T[]`
if (
preg_match('/^array\s*<\s*([\w\\\\|?]+)\s*>$/i', $type, $matches)
preg_match('/^(?:array|list|non-empty-array|non-empty-list)\s*<\s*(.+?)\s*>$/is', $type, $matches)
|| preg_match('/^([\w\\\\]+)\s*\[\]$/i', $type, $matches)
) {
return ['type' => 'array', 'items' => $this->mapTypeToSchema($matches[1])];
return ['type' => 'array', 'items' => $this->mapTypeToSchema($matches[1]) ?: new \stdClass()];
}

if (\in_array(strtolower($type), ['list', 'non-empty-array', 'non-empty-list'], true)) {
return ['type' => 'array'];
}

if ('mixed' === strtolower($type)) {
return [];
}

if (null !== $pseudoTypes = self::mapPseudoTypeToJsonSchemaTypes($type)) {
return ['type' => 1 === \count($pseudoTypes) ? $pseudoTypes[0] : $pseudoTypes];
}

return ['type' => 'null' === strtolower($type) ? 'null' : $this->mapSimpleTypeToJsonSchema($type)];
Expand Down
76 changes: 76 additions & 0 deletions tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,82 @@ public function arrayShapesWithUnionMembers(
): void {
}

/**
* PHPStan/Psalm pseudo-types and literal types map to their base type.
*
* @param non-empty-string $name Name
* @param class-string $class Class
* @param positive-int $count Count
* @param array-key $key Key
* @param 'asc'|'desc' $order Order
* @param 1|2 $level Level
* @param non-empty-list<non-empty-string> $tags Tags
* @param array{tags: list<string>, ids: array<int, int>, meta: array<string, int>, any: mixed} $settings Settings
*/
public function pseudoTypes(
string $name,
string $class,
int $count,
int|string $key,
string $order,
int $level,
array $tags,
array $settings,
): void {
}

/**
* The key type of a map inside an array shape decides between a JSON array and a JSON object.
*
* @param array{map: array<string, int>} $stringKey
* @param array{map: array<lowercase-string, int>} $pseudoStringKey
* @param array{map: array<class-string<\stdClass>, int>} $genericStringKey
* @param array{map: array<int, int>} $intKey
* @param array{map: array<int<0, max>, int>} $rangeKey
* @param array{map: array<array-key, int>} $arrayKey
* @param array{map: array<int|string, int>} $unionKey
* @param array{map: array<array-key, mixed>} $arrayKeyMixedValue
* @param array{map: array<array-key, int>|null} $nullableArrayKey
* @param array{map: array<array{a: int, b: int}>} $shapeValue
*/
public function arrayShapeMapKeys(
array $stringKey,
array $pseudoStringKey,
array $genericStringKey,
array $intKey,
array $rangeKey,
array $arrayKey,
array $unionKey,
array $arrayKeyMixedValue,
array $nullableArrayKey,
array $shapeValue,
): void {
}

/**
* @param ?non-empty-string $nullableString
* @param ?positive-int $nullableInt
* @param ?array-key $nullableArrayKey
* @param ?list<string> $nullableList
* @param non-empty-array<string, int> $stringKeyMap
* @param non-empty-array<string, int>|null $nullableStringKeyMap
* @param non-empty-array<int, int> $intKeyMap
* @param non-empty-array<array-key, int> $arrayKeyMap
* @param non-empty-array<int> $singleArgument
*/
public function nullableAndNonEmptyPseudoTypes(
?string $nullableString,
?int $nullableInt,
int|string|null $nullableArrayKey,
?array $nullableList,
array $stringKeyMap,
?array $nullableStringKeyMap,
array $intKeyMap,
array $arrayKeyMap,
array $singleArgument,
): void {
}

// ===== NULLABLE AND OPTIONAL SCENARIOS =====

/**
Expand Down
Loading
Loading