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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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`, `positive-int`, `'asc'|'desc'`, `array<array-key, T>`, ...) rejecting valid arguments with `-32602`: parameters and array shape keys now get the JSON Schema type of their base type instead of `object`.
* 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
183 changes: 131 additions & 52 deletions src/Capability/Discovery/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -708,17 +708,39 @@ private function getTypeStringFromReflection(?\ReflectionType $type, bool $nativ
*/
public static function splitUnion(string $type): array
{
$branches = [];
return self::splitTopLevel($type, '|');
}

/**
* Splits a type string on a separator outside of generics, shapes, parentheses and quoted literals.
*
* @return list<string>
*/
private static function splitTopLevel(string $type, string $separator): array
{
$parts = [];
$depth = 0;
$current = '';
$quote = null;
$escaped = false;

foreach (str_split($type) as $character) {
if (\in_array($character, ['<', '{', '('], true)) {
if (null !== $quote) {
if ($escaped) {
$escaped = false;
} elseif ('\\' === $character) {
$escaped = true;
} elseif ($quote === $character) {
$quote = null;
}
} elseif ('\'' === $character || '"' === $character) {
$quote = $character;
} elseif (\in_array($character, ['<', '{', '('], true)) {
++$depth;
} elseif (\in_array($character, ['>', '}', ')'], true)) {
$depth = max(0, $depth - 1);
} elseif ('|' === $character && 0 === $depth) {
$branches[] = trim($current);
} elseif ($separator === $character && 0 === $depth) {
$parts[] = trim($current);
$current = '';

continue;
Expand All @@ -727,9 +749,9 @@ public static function splitUnion(string $type): array
$current .= $character;
}

$branches[] = trim($current);
$parts[] = trim($current);

return array_values(array_filter($branches, static fn (string $branch): bool => '' !== $branch));
return array_values(array_filter($parts, static fn (string $part): bool => '' !== $part));
}

/**
Expand All @@ -752,6 +774,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 +799,34 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array
return ['object'];
}

// PRIORITY 3: Check for array syntax (T[] or generics)
// PRIORITY 3: 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;
}

// 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 4: 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 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 +835,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('/^(\'(?:[^\'\\\\]|\\\\.)*\'|"(?:[^"\\\\]|\\\\.)*")$/s', $type)) {
return ['string'];
}
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 +900,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 thread
chr-hertel marked this conversation as resolved.
Resolved

return \is_string($itemSchema['type']) && 1 === \count($itemSchema) ? $itemSchema['type'] : $itemSchema;
}
Expand All @@ -844,7 +917,7 @@ private function inferArrayItemsType(string $phpTypeString): string|array
// Return a schema for array with items being arrays
return [
'type' => 'array',
'items' => $this->mapTypeToSchema(strtolower($matches[2])),
'items' => $this->mapTypeToSchema(strtolower($matches[2])) ?: new \stdClass(),
];
}

Expand All @@ -870,34 +943,8 @@ private function parseObjectLikeArray(string $propertiesStr): array
$properties = [];
$required = [];

// Parse properties from the string, handling nested structures
$depth = 0;
$buffer = '';

for ($i = 0; $i < \strlen($propertiesStr); ++$i) {
$char = $propertiesStr[$i];

// Track nested braces
if ('{' === $char) {
++$depth;
$buffer .= $char;
} elseif ('}' === $char) {
--$depth;
$buffer .= $char;
}
// Property separator (comma)
elseif (',' === $char && 0 === $depth) {
// Process the completed property
$this->parsePropertyDefinition(trim($buffer), $properties, $required);
$buffer = '';
} else {
$buffer .= $char;
}
}

// Process the last property
if (!empty($buffer)) {
$this->parsePropertyDefinition(trim($buffer), $properties, $required);
foreach (self::splitTopLevel($propertiesStr, ',') as $propDefinition) {
$this->parsePropertyDefinition($propDefinition, $properties, $required);
}

if (!empty($properties)) {
Expand All @@ -924,7 +971,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 +991,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 +1013,36 @@ private function mapTypeToSchema(string $type): array
return $this->parseObjectLikeArray($matches[1]);
}

// `array<K, V>`: only string keys rule out a JSON array; sparse int keys arrive as a JSON object
if (preg_match('/^(?:non-empty-)?array\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 ['string'] === $keyTypes
? ['type' => 'object'] + $additionalProperties
: ['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
Loading
Loading