From b98d468021607e7b33b92afc7ef91710209da6fc Mon Sep 17 00:00:00 2001 From: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:55:34 +0000 Subject: [PATCH 1/6] [Capability] Map PHPDoc pseudo-types to the JSON Schema type of their base type Fixes #641 --- CHANGELOG.md | 1 + src/Capability/Discovery/SchemaGenerator.php | 95 ++++++++++++++++--- .../Discovery/SchemaGeneratorFixture.php | 24 +++++ .../Discovery/SchemaGeneratorTest.php | 23 +++++ 4 files changed, 132 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3d1380..d7d51d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, `class-string`, `positive-int`, `array-key`, `'asc'|'desc'`, `non-empty-list`, ...) rejecting valid arguments with `-32602`: such parameters, and such keys of an array shape, get the JSON Schema type of their base type instead of `object`. Inside a shape, `list` and `array` keys become arrays, `array` keys become objects and `mixed` keys accept any value. * Fix `array` 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. diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index c47e4497..4b72de23 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -776,14 +776,16 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array // PRIORITY 3: Check for array syntax (T[] or generics) if ( str_contains($normalizedType, '[]') - || preg_match('/^(array|list|iterable|collection)]+\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 @@ -808,6 +810,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|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']; + } + 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. * @@ -830,8 +875,11 @@ private function inferArrayItemsType(string $phpTypeString): string|array } // Case 2: Generic array syntax (e.g., array, array, 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'; + } return \is_string($itemSchema['type']) && 1 === \count($itemSchema) ? $itemSchema['type'] : $itemSchema; } @@ -877,11 +925,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` stays in its key + if ('{' === $char || '<' === $char) { ++$depth; $buffer .= $char; - } elseif ('}' === $char) { + } elseif ('}' === $char || '>' === $char) { --$depth; $buffer .= $char; } @@ -924,7 +972,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(); } } @@ -943,6 +992,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'; @@ -959,11 +1014,29 @@ private function mapTypeToSchema(string $type): array return $this->parseObjectLikeArray($matches[1]); } + // A map with string keys, `array`, is a JSON object + if (preg_match('/^(?:non-empty-)?array\s*<\s*(?:string|non-empty-string|class-string)\s*,\s*(.+?)\s*>$/is', $type, $matches)) { + return ['type' => 'object', 'additionalProperties' => $this->mapTypeToSchema($matches[1]) ?: true]; + } + + // `array`, `array`, `list`, `non-empty-list`, `non-empty-array` 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*(?:[\w-]+\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)]; diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 971d6870..276434ef 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -335,6 +335,30 @@ 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 $tags Tags + * @param array{tags: list, ids: array, meta: array, 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 { + } + // ===== NULLABLE AND OPTIONAL SCENARIOS ===== /** diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 1e046a13..2fd75cee 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -252,6 +252,29 @@ public function testMapsPhpStanAndPsalmIntegerRangesToBaseIntegerType(): void $this->assertEquals(['type' => 'integer', 'description' => 'Bounded value'], $schema['properties']['bounded']); } + public function testMapsPhpStanAndPsalmPseudoTypesToTheirBaseType(): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'pseudoTypes'); + $schema = $this->schemaGenerator->generate($method); + + $this->assertEquals(['type' => 'string', 'description' => 'Name'], $schema['properties']['name']); + $this->assertEquals(['type' => 'string', 'description' => 'Class'], $schema['properties']['class']); + $this->assertEquals(['type' => 'integer', 'description' => 'Count'], $schema['properties']['count']); + $this->assertEquals(['type' => ['integer', 'string'], 'description' => 'Key'], $schema['properties']['key']); + $this->assertEquals(['type' => 'string', 'description' => 'Order'], $schema['properties']['order']); + $this->assertEquals(['type' => 'integer', 'description' => 'Level'], $schema['properties']['level']); + $this->assertEquals(['type' => 'array', 'description' => 'Tags', 'items' => ['type' => 'string']], $schema['properties']['tags']); + + $settings = $schema['properties']['settings']; + $this->assertSame('object', $settings['type']); + $this->assertEquals(['type' => 'array', 'items' => ['type' => 'string']], $settings['properties']['tags']); + $this->assertEquals(['type' => 'array', 'items' => ['type' => 'integer']], $settings['properties']['ids']); + $this->assertEquals(['type' => 'object', 'additionalProperties' => ['type' => 'integer']], $settings['properties']['meta']); + // `mixed` accepts any value, serialized as the empty schema `{}` + $this->assertSame('{}', json_encode($settings['properties']['any'])); + $this->assertEqualsCanonicalizing(['tags', 'ids', 'meta', 'any'], $settings['required']); + } + public function testGeneratesCorrectSchemaForEnumParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); From 4c3b15b69ae640e1a01d48c85069f894b309f4af Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 2 Nov 2026 21:45:14 +0100 Subject: [PATCH 2/6] [Capability] Derive the JSON type of a map inside a shape from its key type --- CHANGELOG.md | 2 +- src/Capability/Discovery/SchemaGenerator.php | 19 ++++-- .../Discovery/SchemaGeneratorFixture.php | 28 +++++++++ .../Discovery/SchemaGeneratorTest.php | 63 +++++++++++++++++++ 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d51d5a..b8bb52bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +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`, `class-string`, `positive-int`, `array-key`, `'asc'|'desc'`, `non-empty-list`, ...) rejecting valid arguments with `-32602`: such parameters, and such keys of an array shape, get the JSON Schema type of their base type instead of `object`. Inside a shape, `list` and `array` keys become arrays, `array` keys become objects and `mixed` keys accept any value. +* 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`, ...) rejecting valid arguments with `-32602`: such parameters, and such keys of an array shape, get the JSON Schema type of their base type instead of `object`. Inside a shape, `list` and `array` keys become arrays, `array` keys become objects, `array` keys accept either and `mixed` keys accept any value. * Fix `array` 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. diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 4b72de23..42c94240 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -1014,14 +1014,23 @@ private function mapTypeToSchema(string $type): array return $this->parseObjectLikeArray($matches[1]); } - // A map with string keys, `array`, is a JSON object - if (preg_match('/^(?:non-empty-)?array\s*<\s*(?:string|non-empty-string|class-string)\s*,\s*(.+?)\s*>$/is', $type, $matches)) { - return ['type' => 'object', 'additionalProperties' => $this->mapTypeToSchema($matches[1]) ?: true]; + // `array`: 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`, `array`, `list`, `non-empty-list`, `non-empty-array` and `T[]` + // `array`, `list`, `non-empty-list`, `non-empty-array` and `T[]` if ( - preg_match('/^(?:array|list|non-empty-array|non-empty-list)\s*<\s*(?:[\w-]+\s*,\s*)?(.+?)\s*>$/is', $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]) ?: new \stdClass()]; diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 276434ef..2199d2bb 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -359,6 +359,34 @@ public function pseudoTypes( ): void { } + /** + * The key type of a map inside an array shape decides between a JSON array and a JSON object. + * + * @param array{map: array} $stringKey + * @param array{map: array} $pseudoStringKey + * @param array{map: array, int>} $genericStringKey + * @param array{map: array} $intKey + * @param array{map: array, int>} $rangeKey + * @param array{map: array} $arrayKey + * @param array{map: array} $unionKey + * @param array{map: array} $arrayKeyMixedValue + * @param array{map: array|null} $nullableArrayKey + * @param array{map: array} $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 { + } + // ===== NULLABLE AND OPTIONAL SCENARIOS ===== /** diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 2fd75cee..4155b950 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -13,6 +13,7 @@ use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\SchemaGenerator; +use Mcp\Capability\Discovery\SchemaValidator; use Mcp\Exception\InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -275,6 +276,68 @@ public function testMapsPhpStanAndPsalmPseudoTypesToTheirBaseType(): void $this->assertEqualsCanonicalizing(['tags', 'ids', 'meta', 'any'], $settings['required']); } + /** + * @param array $expected + * @param list $valid JSON values of the `map` key that must pass validation + * @param list $invalid JSON values of the `map` key that must fail validation + */ + #[DataProvider('provideArrayShapeMapKeys')] + public function testMapsTheKeyTypeOfAMapInsideAnArrayShape(string $parameter, array $expected, array $valid, array $invalid): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayShapeMapKeys'); + $schema = $this->schemaGenerator->generate($method)['properties'][$parameter]; + + $this->assertEquals($expected, $schema['properties']['map']); + + $validator = new SchemaValidator(); + foreach ($valid as $map) { + $this->assertSame([], $validator->validateAgainstJsonSchema(['map' => json_decode($map, true)], $schema), $map); + } + foreach ($invalid as $map) { + $this->assertNotSame([], $validator->validateAgainstJsonSchema(['map' => json_decode($map, true)], $schema), $map); + } + } + + /** + * @return iterable, list, list}> + */ + public static function provideArrayShapeMapKeys(): iterable + { + $object = ['type' => 'object', 'additionalProperties' => ['type' => 'integer']]; + $list = ['type' => 'array', 'items' => ['type' => 'integer']]; + $either = ['type' => ['array', 'object'], 'items' => ['type' => 'integer'], 'additionalProperties' => ['type' => 'integer']]; + + yield 'string' => ['stringKey', $object, ['{"x":1}'], ['{"x":"y"}', '"x"']]; + yield 'string pseudo-type' => ['pseudoStringKey', $object, ['{"x":1}'], ['{"x":"y"}', '"x"']]; + yield 'generic string pseudo-type' => ['genericStringKey', $object, ['{"stdClass":1}'], ['{"stdClass":"y"}', '"x"']]; + yield 'int' => ['intKey', $list, ['[1]', '[]'], ['["y"]', '{"x":1}']]; + yield 'int range' => ['rangeKey', $list, ['[1]', '[]'], ['["y"]', '{"x":1}']]; + yield 'array-key' => ['arrayKey', $either, ['{"x":1}', '[1]', '{}', '{"0":1}'], ['{"x":"y"}', '["y"]', '"x"', 'null']]; + yield 'int|string' => ['unionKey', $either, ['{"x":1}', '[1]', '{}', '{"0":1}'], ['{"x":"y"}', '["y"]', '"x"', 'null']]; + yield 'array-key with mixed value' => [ + 'arrayKeyMixedValue', + ['type' => ['array', 'object'], 'items' => new \stdClass(), 'additionalProperties' => true], + ['{"x":1}', '{"x":"y"}', '[1,"y"]', '{}'], + ['"x"', 'null'], + ]; + yield 'nullable array-key' => [ + 'nullableArrayKey', + ['type' => ['array', 'object', 'null']] + $either, + ['{"x":1}', '[1]', 'null'], + ['{"x":"y"}', '["y"]', '"x"'], + ]; + yield 'single argument with a shape value' => [ + 'shapeValue', + ['type' => 'array', 'items' => [ + 'type' => 'object', + 'properties' => ['a' => ['type' => 'integer'], 'b' => ['type' => 'integer']], + 'required' => ['a', 'b'], + ]], + ['[{"a":1,"b":2}]', '[]'], + ['[{"a":1}]', '{"x":{"a":1,"b":2}}'], + ]; + } + public function testGeneratesCorrectSchemaForEnumParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); From c3cbbb1c7ed31e0c5784804322cc0aa7e50886db Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Mon, 2 Nov 2026 21:46:00 +0100 Subject: [PATCH 3/6] [Capability] Fix top-level `?T` pseudo-types and `non-empty-array` schemas --- CHANGELOG.md | 2 +- src/Capability/Discovery/SchemaGenerator.php | 17 +++++---- .../Discovery/SchemaGeneratorFixture.php | 24 ++++++++++++ .../Discovery/SchemaGeneratorTest.php | 38 +++++++++++++++++++ 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8bb52bb..28fc45ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +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`, `class-string`, `positive-int`, `array-key`, `'asc'|'desc'`, `non-empty-list`, ...) rejecting valid arguments with `-32602`: such parameters, and such keys of an array shape, get the JSON Schema type of their base type instead of `object`. Inside a shape, `list` and `array` keys become arrays, `array` keys become objects, `array` keys accept either and `mixed` keys accept any value. +* 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`, ...) 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` and `array` keys become arrays, `array` keys become objects, `array` keys accept either and `mixed` keys accept any value. * Fix `array` 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. diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 42c94240..b93220b4 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -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`) and is not a union of the whole type. @@ -773,10 +777,15 @@ 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*mapTypeToSchema($normalizedType)['type']; + } + // PRIORITY 3: Check for array syntax (T[] or generics) if ( str_contains($normalizedType, '[]') - || preg_match('/^(array|list|non-empty-array|non-empty-list|iterable|collection) ['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' => [], diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 2199d2bb..10cfb2cf 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -387,6 +387,30 @@ public function arrayShapeMapKeys( ): void { } + /** + * @param ?non-empty-string $nullableString + * @param ?positive-int $nullableInt + * @param ?array-key $nullableArrayKey + * @param ?list $nullableList + * @param non-empty-array $stringKeyMap + * @param non-empty-array|null $nullableStringKeyMap + * @param non-empty-array $intKeyMap + * @param non-empty-array $arrayKeyMap + * @param non-empty-array $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 ===== /** diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 4155b950..4befc330 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -338,6 +338,44 @@ public static function provideArrayShapeMapKeys(): iterable ]; } + /** + * @param array $expected + * @param list $valid JSON arguments that must pass validation + * @param list $invalid JSON arguments that must fail validation + */ + #[DataProvider('provideNullableAndNonEmptyPseudoTypes')] + public function testMapsNullableAndNonEmptyPseudoTypes(string $parameter, array $expected, array $valid, array $invalid): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndNonEmptyPseudoTypes'); + $schema = $this->schemaGenerator->generate($method)['properties'][$parameter]; + + $this->assertEquals($expected, $schema); + + $validator = new SchemaValidator(); + foreach ($valid as $argument) { + $this->assertSame([], $validator->validateAgainstJsonSchema(json_decode($argument, true), $schema), $argument); + } + foreach ($invalid as $argument) { + $this->assertNotSame([], $validator->validateAgainstJsonSchema(json_decode($argument, true), $schema), $argument); + } + } + + /** + * @return iterable, list, list}> + */ + public static function provideNullableAndNonEmptyPseudoTypes(): iterable + { + yield '?non-empty-string' => ['nullableString', ['type' => ['null', 'string']], ['"a"', 'null'], ['1']]; + yield '?positive-int' => ['nullableInt', ['type' => ['null', 'integer']], ['1', 'null'], ['"a"']]; + yield '?array-key' => ['nullableArrayKey', ['type' => ['null', 'integer', 'string']], ['1', '"a"', 'null'], ['true']]; + yield '?list' => ['nullableList', ['type' => ['array', 'null'], 'items' => ['type' => 'string']], ['["a"]', 'null'], ['[1]', '"a"']]; + yield 'non-empty-array' => ['stringKeyMap', ['type' => 'object'], ['{"a":1}'], ['["a"]', '"a"']]; + yield 'non-empty-array|null' => ['nullableStringKeyMap', ['type' => ['null', 'object']], ['{"a":1}', 'null'], ['["a"]']]; + yield 'non-empty-array' => ['intKeyMap', ['type' => 'array', 'items' => new \stdClass()], ['[1]'], ['{"a":1}']]; + yield 'non-empty-array' => ['arrayKeyMap', ['type' => ['array', 'object'], 'items' => new \stdClass()], ['{"a":1}', '[1]'], ['"a"']]; + yield 'non-empty-array' => ['singleArgument', ['type' => 'array', 'items' => ['type' => 'integer']], ['[1]'], ['["a"]', '{"a":1}']]; + } + public function testGeneratesCorrectSchemaForEnumParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); From aed681c297d35735ceac90cbaa8442ae767a84f3 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 3 Nov 2026 01:40:38 +0100 Subject: [PATCH 4/6] [Capability] Fix quoted literals with structural characters and nested `mixed` schemas --- CHANGELOG.md | 2 +- src/Capability/Discovery/SchemaGenerator.php | 77 ++++++++++--------- .../Discovery/SchemaGeneratorFixture.php | 30 ++++++++ .../Discovery/SchemaGeneratorTest.php | 48 ++++++++++++ 4 files changed, 118 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28fc45ca..283fbfb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +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`, `class-string`, `positive-int`, `array-key`, `'asc'|'desc'`, `non-empty-list`, ...) 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` and `array` keys become arrays, `array` keys become objects, `array` keys accept either and `mixed` keys accept any value. +* 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`, ...) 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`. Quoted literals may contain `|`, `,` and brackets. Inside a shape, `list` and `array` keys become arrays, `array` keys become objects, `array` keys accept either and `mixed` keys accept any value. * Fix `array` 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. diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index b93220b4..bc915fa9 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -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 + */ + 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; @@ -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)); } /** @@ -777,6 +799,11 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array return ['object']; } + // A literal can contain anything, e.g. `'a[]'` + if ('\'' === ($normalizedType[0] ?? '') || '"' === ($normalizedType[0] ?? '')) { + return ['string']; + } + // 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*mapTypeToSchema($normalizedType)['type']; @@ -833,7 +860,7 @@ private static function mapPseudoTypeToJsonSchemaTypes(string $type): ?array } // Literal types: 'foo', "foo", 42, -1.5 - if (preg_match('/^(\'[^\']*\'|"[^"]*")$/', $type)) { + if (preg_match('/^(\'(?:[^\'\\\\]|\\\\.)*\'|"(?:[^"\\\\]|\\\\.)*")$/s', $type)) { return ['string']; } if (preg_match('/^-?\d+$/', $type)) { @@ -878,7 +905,7 @@ private function inferArrayItemsType(string $phpTypeString): string|array } // Case 2: Generic array syntax (e.g., array, array, etc.) - if (preg_match('/^(\\??)(?:array|list|non-empty-array|non-empty-list)\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'; @@ -895,7 +922,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(), ]; } @@ -921,34 +948,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 and generics, so the comma of `array` stays in its key - if ('{' === $char || '<' === $char) { - ++$depth; - $buffer .= $char; - } elseif ('}' === $char || '>' === $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)) { @@ -1018,7 +1019,7 @@ private function mapTypeToSchema(string $type): array } // `array`: 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)) { + 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()]; diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php index 10cfb2cf..2d529e5a 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php @@ -411,6 +411,36 @@ public function nullableAndNonEmptyPseudoTypes( ): void { } + /** + * @param 'a|b'|'c' $pipeLiteral + * @param "a|b" $doubleQuotedPipeLiteral + * @param 'a[]' $bracketLiteral + * @param 'it\'s' $escapedLiteral + * @param array{operator: '<', limit: int} $lessThanLiteral + * @param array{operator: '>', limit: int} $greaterThanLiteral + * @param array{label: 'a,b', limit: int} $commaLiteral + * @param array{label: '{', limit: int} $braceLiteral + * @param list<'a,b'> $commaLiteralList + * @param array> $nestedMixed + * @param array> $nestedMixedUnion + * @param mixed[][] $nestedMixedBrackets + */ + public function quotedLiteralsAndNestedMixed( + string $pipeLiteral, + string $doubleQuotedPipeLiteral, + string $bracketLiteral, + string $escapedLiteral, + array $lessThanLiteral, + array $greaterThanLiteral, + array $commaLiteral, + array $braceLiteral, + array $commaLiteralList, + array $nestedMixed, + array $nestedMixedUnion, + array $nestedMixedBrackets, + ): void { + } + // ===== NULLABLE AND OPTIONAL SCENARIOS ===== /** diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 4befc330..8669fba1 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -376,6 +376,54 @@ public static function provideNullableAndNonEmptyPseudoTypes(): iterable yield 'non-empty-array' => ['singleArgument', ['type' => 'array', 'items' => ['type' => 'integer']], ['[1]'], ['["a"]', '{"a":1}']]; } + /** + * @param array $expected + * @param list $valid JSON arguments that must pass validation + * @param list $invalid JSON arguments that must fail validation + */ + #[DataProvider('provideQuotedLiteralsAndNestedMixed')] + public function testMapsQuotedLiteralsAndNestedMixed(string $parameter, array $expected, array $valid, array $invalid): void + { + $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'quotedLiteralsAndNestedMixed'); + $schema = $this->schemaGenerator->generate($method)['properties'][$parameter]; + + $this->assertEquals($expected, $schema); + + $validator = new SchemaValidator(); + foreach ($valid as $argument) { + $this->assertSame([], $validator->validateAgainstJsonSchema(json_decode($argument, true), $schema), $argument); + } + foreach ($invalid as $argument) { + $this->assertNotSame([], $validator->validateAgainstJsonSchema(json_decode($argument, true), $schema), $argument); + } + } + + /** + * @return iterable, list, list}> + */ + public static function provideQuotedLiteralsAndNestedMixed(): iterable + { + $shape = static fn (string $key): array => [ + 'type' => 'object', + 'properties' => [$key => ['type' => 'string'], 'limit' => ['type' => 'integer']], + 'required' => [$key, 'limit'], + ]; + $nested = ['type' => 'array', 'items' => ['type' => 'array', 'items' => new \stdClass()]]; + + yield 'pipe in a literal' => ['pipeLiteral', ['type' => 'string'], ['"a|b"', '"c"'], ['1']]; + yield 'pipe in a double quoted literal' => ['doubleQuotedPipeLiteral', ['type' => 'string'], ['"a|b"'], ['1']]; + yield 'brackets in a literal' => ['bracketLiteral', ['type' => 'string'], ['"a[]"'], ['["a"]']]; + yield 'escaped quote in a literal' => ['escapedLiteral', ['type' => 'string'], ['"it\'s"'], ['1']]; + yield 'less than in a shape literal' => ['lessThanLiteral', $shape('operator'), ['{"operator":"<","limit":1}'], ['{"operator":"<"}', '{"operator":1,"limit":1}']]; + yield 'greater than in a shape literal' => ['greaterThanLiteral', $shape('operator'), ['{"operator":">","limit":1}'], ['{"operator":">"}']]; + yield 'comma in a shape literal' => ['commaLiteral', $shape('label'), ['{"label":"a,b","limit":1}'], ['{"label":"a,b"}']]; + yield 'brace in a shape literal' => ['braceLiteral', $shape('label'), ['{"label":"{","limit":1}'], ['{"label":"{"}']]; + yield 'comma in a list literal' => ['commaLiteralList', ['type' => 'array', 'items' => ['type' => 'string']], ['["a,b"]'], ['[1]']]; + yield 'nested mixed' => ['nestedMixed', $nested, ['[[1,"x"]]', '[]'], ['[1]', '"x"']]; + yield 'nested mixed union' => ['nestedMixedUnion', $nested, ['[[1,"x"]]'], ['[1]']]; + yield 'nested mixed brackets' => ['nestedMixedBrackets', $nested, ['[[1,"x"]]'], ['[1]']]; + } + public function testGeneratesCorrectSchemaForEnumParameters(): void { $method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters'); From 148324bc1831bf6f400cf720b80548ee518966e7 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 3 Nov 2026 01:53:50 +0100 Subject: [PATCH 5/6] [Capability] Accept sparse `array` maps and keep arrays of literals as arrays --- CHANGELOG.md | 2 +- src/Capability/Discovery/SchemaGenerator.php | 27 +++++++------------ .../Discovery/SchemaGeneratorTest.php | 9 +++---- .../Discovery/SchemaGeneratorUnionTest.php | 4 +++ 4 files changed, 19 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 283fbfb5..343570ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +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`, `class-string`, `positive-int`, `array-key`, `'asc'|'desc'`, `non-empty-list`, ...) 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`. Quoted literals may contain `|`, `,` and brackets. Inside a shape, `list` and `array` keys become arrays, `array` keys become objects, `array` keys accept either and `mixed` keys accept any value. +* 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`, ...) 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`. Quoted literals may contain `|`, `,` and brackets. Inside a shape, `list` keys become arrays, `array` keys become objects, `array` and `array` keys accept either and `mixed` keys accept any value. * Fix `array` 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. diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index bc915fa9..c079aaad 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -799,9 +799,10 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array return ['object']; } - // A literal can contain anything, e.g. `'a[]'` - if ('\'' === ($normalizedType[0] ?? '') || '"' === ($normalizedType[0] ?? '')) { - return ['string']; + // 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. @@ -809,7 +810,7 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array return (array) $this->mapTypeToSchema($normalizedType)['type']; } - // PRIORITY 3: Check for array syntax (T[] or generics) + // PRIORITY 4: Check for array syntax (T[] or generics) if ( str_contains($normalizedType, '[]') || preg_match('/^(array|list|non-empty-list|iterable|collection) ['string'], @@ -1018,18 +1013,16 @@ private function mapTypeToSchema(string $type): array return $this->parseObjectLikeArray($matches[1]); } - // `array`: 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)) { + // `array`: 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 match ($keyTypes) { - ['integer'] => ['type' => 'array'] + $items, - ['string'] => ['type' => 'object'] + $additionalProperties, - default => ['type' => ['array', 'object']] + $items + $additionalProperties, - }; + return ['string'] === $keyTypes + ? ['type' => 'object'] + $additionalProperties + : ['type' => ['array', 'object']] + $items + $additionalProperties; } // `array`, `list`, `non-empty-list`, `non-empty-array` and `T[]` diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 8669fba1..28413096 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -269,7 +269,7 @@ public function testMapsPhpStanAndPsalmPseudoTypesToTheirBaseType(): void $settings = $schema['properties']['settings']; $this->assertSame('object', $settings['type']); $this->assertEquals(['type' => 'array', 'items' => ['type' => 'string']], $settings['properties']['tags']); - $this->assertEquals(['type' => 'array', 'items' => ['type' => 'integer']], $settings['properties']['ids']); + $this->assertEquals(['type' => ['array', 'object'], 'items' => ['type' => 'integer'], 'additionalProperties' => ['type' => 'integer']], $settings['properties']['ids']); $this->assertEquals(['type' => 'object', 'additionalProperties' => ['type' => 'integer']], $settings['properties']['meta']); // `mixed` accepts any value, serialized as the empty schema `{}` $this->assertSame('{}', json_encode($settings['properties']['any'])); @@ -304,14 +304,13 @@ public function testMapsTheKeyTypeOfAMapInsideAnArrayShape(string $parameter, ar public static function provideArrayShapeMapKeys(): iterable { $object = ['type' => 'object', 'additionalProperties' => ['type' => 'integer']]; - $list = ['type' => 'array', 'items' => ['type' => 'integer']]; $either = ['type' => ['array', 'object'], 'items' => ['type' => 'integer'], 'additionalProperties' => ['type' => 'integer']]; yield 'string' => ['stringKey', $object, ['{"x":1}'], ['{"x":"y"}', '"x"']]; yield 'string pseudo-type' => ['pseudoStringKey', $object, ['{"x":1}'], ['{"x":"y"}', '"x"']]; yield 'generic string pseudo-type' => ['genericStringKey', $object, ['{"stdClass":1}'], ['{"stdClass":"y"}', '"x"']]; - yield 'int' => ['intKey', $list, ['[1]', '[]'], ['["y"]', '{"x":1}']]; - yield 'int range' => ['rangeKey', $list, ['[1]', '[]'], ['["y"]', '{"x":1}']]; + yield 'int' => ['intKey', $either, ['[1]', '[]', '{"5":1}'], ['["y"]', '{"5":"y"}', '"x"']]; + yield 'int range' => ['rangeKey', $either, ['[1]', '[]', '{"5":1}'], ['["y"]', '{"5":"y"}', '"x"']]; yield 'array-key' => ['arrayKey', $either, ['{"x":1}', '[1]', '{}', '{"0":1}'], ['{"x":"y"}', '["y"]', '"x"', 'null']]; yield 'int|string' => ['unionKey', $either, ['{"x":1}', '[1]', '{}', '{"0":1}'], ['{"x":"y"}', '["y"]', '"x"', 'null']]; yield 'array-key with mixed value' => [ @@ -371,7 +370,7 @@ public static function provideNullableAndNonEmptyPseudoTypes(): iterable yield '?list' => ['nullableList', ['type' => ['array', 'null'], 'items' => ['type' => 'string']], ['["a"]', 'null'], ['[1]', '"a"']]; yield 'non-empty-array' => ['stringKeyMap', ['type' => 'object'], ['{"a":1}'], ['["a"]', '"a"']]; yield 'non-empty-array|null' => ['nullableStringKeyMap', ['type' => ['null', 'object']], ['{"a":1}', 'null'], ['["a"]']]; - yield 'non-empty-array' => ['intKeyMap', ['type' => 'array', 'items' => new \stdClass()], ['[1]'], ['{"a":1}']]; + yield 'non-empty-array' => ['intKeyMap', ['type' => ['array', 'object'], 'items' => new \stdClass()], ['[1]', '{"5":1}'], ['"a"']]; yield 'non-empty-array' => ['arrayKeyMap', ['type' => ['array', 'object'], 'items' => new \stdClass()], ['{"a":1}', '[1]'], ['"a"']]; yield 'non-empty-array' => ['singleArgument', ['type' => 'array', 'items' => ['type' => 'integer']], ['[1]'], ['["a"]', '{"a":1}']]; } diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php index 9a8a6cf0..eb5672bd 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorUnionTest.php @@ -67,6 +67,10 @@ public static function mappedTypes(): iterable yield 'a generic union is still one array' => ['array', ['array']]; yield 'a shape is still one object' => ['array{a: int|string}', ['object']]; + + yield 'a literal stays a string' => ["'a[]'", ['string']]; + yield 'an array of literals stays an array' => ["'asc'[]", ['array']]; + yield 'an array of bracket literals stays an array' => ['"a[]"[]', ['array']]; } /** From 54c4a5c8b5167bf3fbdb1f789d75101f85ec7f02 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 3 Nov 2026 01:54:17 +0100 Subject: [PATCH 6/6] [Capability] Shorten the changelog entry for PHPDoc pseudo-types --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 343570ec..0b9a013a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +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`, `class-string`, `positive-int`, `array-key`, `'asc'|'desc'`, `non-empty-list`, ...) 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`. Quoted literals may contain `|`, `,` and brackets. Inside a shape, `list` keys become arrays, `array` keys become objects, `array` and `array` keys accept either and `mixed` keys accept any value. +* Fix tools whose DocBlock uses a PHPStan/Psalm pseudo-type or a literal type (`non-empty-string`, `positive-int`, `'asc'|'desc'`, `array`, ...) 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` 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.