From 4ded5c4922c83778a501f684bba5526ff9beb3f4 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 12:00:43 +0100 Subject: [PATCH 1/6] [Server] Keep JSON types of tool call arguments --- CHANGELOG.md | 5 + docs/servers/schemas.md | 31 ++++ src/Capability/Discovery/SchemaGenerator.php | 10 +- src/Capability/Registry/ReferenceHandler.php | 21 ++- src/JsonRpc/Json.php | 37 ++++ src/JsonRpc/MessageFactory.php | 45 ++++- src/Schema/JsonRpc/Notification.php | 9 +- src/Schema/JsonRpc/Request.php | 18 +- src/Schema/Request/CallToolRequest.php | 31 ++-- .../Handler/Request/CallToolHandler.php | 3 +- .../Stateless/OriginatingRequestBinder.php | 22 ++- src/Server/Stateless/StatelessProtocol.php | 10 +- tests/Integration/Fixture/request_state.php | 30 ---- tests/Integration/RequestStateBindingTest.php | 10 -- tests/Integration/StatelessLifecycleTest.php | 12 +- .../Discovery/SchemaGeneratorTest.php | 44 ++++- .../Registry/ReferenceHandlerTest.php | 19 +++ .../Client/Stateless/HeaderFactoryTest.php | 2 +- tests/Unit/JsonRpc/MessageFactoryTest.php | 45 ++++- .../Schema/Request/CallToolRequestTest.php | 75 +++++++++ tests/Unit/Server/BuilderTest.php | 2 +- .../Handler/Request/CallToolHandlerTest.php | 2 +- tests/Unit/Server/RequestContextTest.php | 2 +- .../OriginatingRequestBinderTest.php | 30 ++++ .../Stateless/StandardHeaderValidatorTest.php | 8 +- .../Stateless/StatelessProtocolTest.php | 48 +++--- .../Server/Transport/StdioDualEraTest.php | 159 +++++++++++++++++- 27 files changed, 601 insertions(+), 129 deletions(-) create mode 100644 src/JsonRpc/Json.php create mode 100644 tests/Unit/Schema/Request/CallToolRequestTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7392d5d75..2ee9d9166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,11 @@ All notable changes to `mcp/sdk` will be documented in this file. * Fix a handler still running after its request was answered, e.g. without a fiber scheduler: it no longer fires `ResponseEvent` or `ErrorEvent`, what it throws is logged, and calling the `ClientGateway` throws. * [BC Break] Bind handshake-era sessions to the principal that opened them: `AccessToken::getPrincipal()`, or the `StreamableHttpTransport::PRINCIPAL_ATTRIBUTE` request attribute when set. Requests of another principal, and to sessions opened before the upgrade, are answered with `404`, and to a session the store cannot read with `503`. `Protocol::processInput()`, the transports' `handleMessage()`, `handleSessionEnd()`, `handlePostRequest()` and `handleDeleteRequest()`, and the `onMessage()` and `onSessionEnd()` listeners take a `?string $principal` as last argument. * [BC Break] Answer malformed JSON and an `initialize` sent with a session id with HTTP `400`, and a failure before the session is checked with `500`, instead of `200`. Such an answer no longer carries, or removes, the messages queued for the session in `Mcp-Session-Id`. +* [BC Break] Keep the JSON types of `tools/call` arguments, so validation sees what the client sent: `{}` and `{"0":"x"}` are objects, `[]` and `["x"]` are arrays. Nested JSON objects in `CallToolRequest::$arguments` and in the arguments of `ToolHandlerInterface::execute()` are `\stdClass`. +* [BC Break] Tool parameters without a type, or typed `mixed`, receive nested JSON objects as `\stdClass`. Parameters typed `array` still receive arrays at every level. Parameters typed `object` or `\stdClass`, which always failed with a `TypeError`, receive `\stdClass`. +* [BC Break] Reject `tools/call` when `arguments` is not a JSON object with `-32600`, including `"arguments": []`. +* [BC Break] Reject a JSON-RPC message with a member name starting with a NUL byte with `-32600`, as a PHP object cannot hold such a name. +* [BC Break] `array` parameters generate `{"type": "object"}` instead of `{"type": "array"}`, and `array` parameters accept a JSON array or a JSON object. ### Client diff --git a/docs/servers/schemas.md b/docs/servers/schemas.md index 1bbcf3b25..69c3178f3 100644 --- a/docs/servers/schemas.md +++ b/docs/servers/schemas.md @@ -184,6 +184,37 @@ and `validation_errors` contains all of them. The SDK also validates structured tool output against the tool's `outputSchema`, see [Output validation](tools.md#output-validation). +## Object and Array Arguments + +The type of a parameter decides how your method receives a JSON object: + +```php +#[McpTool] +public function searchOrders( + array $filters, // JSON objects arrive as arrays, at every level + \stdClass $options, // JSON objects arrive as \stdClass, JSON arrays as lists + mixed $metadata, // same as \stdClass; also applies to an untyped parameter +): array +{ + // ... +} +``` + +The `array` type has no distinction between `{}` and `[]`, because both arrive as an empty array. Use `\stdClass` or +`object` if your method needs to tell them apart. The `execute()` method of `ToolHandlerInterface` receives the arguments +as an array, and the nested values follow the same rule as a `\stdClass` parameter. + +The docblock decides which JSON type the client can send to an `array` parameter: + +```php +/** + * @param array $stock a JSON object + * @param string[] $labels a JSON array + * @param array $names a JSON array or a JSON object + * @param array $values a JSON array + */ +``` + ## Customizing the Generator and Validator The SDK validates with [opis/json-schema](https://opis.io/json-schema/). To configure it, for example to resolve diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index c079aaad0..20ed747ea 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -308,6 +308,12 @@ private function buildInferredParameterSchema(array $paramInfo): array // Add default value only if parameter actually has a default if ($paramInfo['has_default']) { $paramSchema['default'] = $paramInfo['default_value']; + + // An empty PHP array encodes as `[]`, which a client could not send back to an object-only parameter. + $types = (array) ($paramSchema['type'] ?? []); + if ([] === $paramSchema['default'] && \in_array('object', $types, true) && !\in_array('array', $types, true)) { + $paramSchema['default'] = new \stdClass(); + } } // Handle enums @@ -805,8 +811,8 @@ private function mapPhpTypeToJsonSchemaType(string $phpTypeString): array 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*$/s', $normalizedType)) { return (array) $this->mapTypeToSchema($normalizedType)['type']; } diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 06498bc2c..e0e4d5c47 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -13,6 +13,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RegistryException; +use Mcp\JsonRpc\Json; use Mcp\Server\Session\SessionInterface; use Psr\Container\ContainerInterface; @@ -205,6 +206,10 @@ private function castArgumentType(mixed $argument, \ReflectionParameter $paramet } } + if ($type instanceof \ReflectionUnionType) { + return self::acceptsOnlyArrays($type) ? Json::objectsToArrays($argument) : $argument; + } + if (!$type instanceof \ReflectionNamedType) { return $argument; } @@ -242,7 +247,7 @@ private function castArgumentType(mixed $argument, \ReflectionParameter $paramet 'string' => (string) $argument, 'bool', 'boolean' => $this->castToBoolean($argument), 'float', 'double' => $this->castToFloat($argument), - 'array' => $this->castToArray($argument), + 'array', 'iterable' => $this->castToArray($argument), default => $argument, }; } catch (\TypeError $e) { @@ -250,6 +255,16 @@ private function castArgumentType(mixed $argument, \ReflectionParameter $paramet } } + private static function acceptsOnlyArrays(\ReflectionUnionType $type): bool + { + $names = array_map( + static fn (\ReflectionType $branch): string => $branch instanceof \ReflectionNamedType ? strtolower($branch->getName()) : '', + $type->getTypes(), + ); + + return [] !== array_intersect($names, ['array', 'iterable']) && [] === array_intersect($names, ['object', 'stdclass']); + } + /** * Helper to cast strictly to boolean. */ @@ -307,10 +322,12 @@ private function castToFloat(mixed $argument): float /** * Helper to cast strictly to array. * - * @return array + * @return array */ private function castToArray(mixed $argument): array { + $argument = Json::objectsToArrays($argument); + if (\is_array($argument)) { return $argument; } diff --git a/src/JsonRpc/Json.php b/src/JsonRpc/Json.php new file mode 100644 index 000000000..a9341343f --- /dev/null +++ b/src/JsonRpc/Json.php @@ -0,0 +1,37 @@ + + */ +final class Json +{ + public static function objectsToArrays(mixed $value): mixed + { + if ($value instanceof \stdClass) { + $value = get_object_vars($value); + } + + if (!\is_array($value)) { + return $value; + } + + foreach ($value as $key => $member) { + $value[$key] = self::objectsToArrays($member); + } + + return $value; + } +} diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index a4861cbf2..71acfae5b 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -99,33 +99,57 @@ public static function make(array $additional = []): self */ public function create(string $input): MessageInterface { - $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); + try { + $data = json_decode($input, false, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + if (\JSON_ERROR_INVALID_PROPERTY_NAME !== $e->getCode()) { + throw $e; + } + + throw self::invalidMemberName($input); + } - // json_decode(assoc: true) maps objects and arrays alike to PHP arrays, so {} reads as a list too. - if (!\is_array($data) || array_is_list($data)) { + if (!$data instanceof \stdClass) { throw new InvalidInputMessageException('A JSON-RPC message must be a single JSON object.'); } try { return $this->createMessage($data); } catch (InvalidInputMessageException $e) { - if (\is_string($data['id'] ?? null) || \is_int($data['id'] ?? null)) { - $e->setRequestId($data['id']); + $id = $data->id ?? null; + if (\is_string($id) || \is_int($id)) { + $e->setRequestId($id); } throw $e; } } + /** + * Valid JSON, but a PHP object cannot hold a property whose name starts with NUL. + */ + private static function invalidMemberName(string $input): InvalidInputMessageException + { + $exception = new InvalidInputMessageException('A JSON-RPC message must not have a member name starting with a NUL byte.'); + $id = json_decode($input, true)['id'] ?? null; + + if (\is_string($id) || \is_int($id)) { + $exception->setRequestId($id); + } + + return $exception; + } + /** * Creates a single message object from parsed JSON data. * - * @param array $data - * * @throws InvalidInputMessageException */ - private function createMessage(array $data): MessageInterface + private function createMessage(\stdClass $message): MessageInterface { + /** @var array $data */ + $data = Json::objectsToArrays($message); + try { if (isset($data['error'])) { return Error::fromArray($data); @@ -145,6 +169,11 @@ private function createMessage(array $data): MessageInterface $messageClass = $this->findMessageClassByMethod($data['method']); + // Each message knows which of its params are free-form JSON, so it gets them as decoded. + if (isset($message->params)) { + $data['params'] = $message->params; + } + return $messageClass::fromArray($data); } catch (InvalidArgumentException $e) { throw new InvalidInputMessageException($e->getMessage(), 0, $e); diff --git a/src/Schema/JsonRpc/Notification.php b/src/Schema/JsonRpc/Notification.php index 3ec0883ad..462c36054 100644 --- a/src/Schema/JsonRpc/Notification.php +++ b/src/Schema/JsonRpc/Notification.php @@ -12,12 +12,13 @@ namespace Mcp\Schema\JsonRpc; use Mcp\Exception\InvalidArgumentException; +use Mcp\JsonRpc\Json; /** * @phpstan-type NotificationData array{ * jsonrpc: string, * method: string, - * params?: array|null + * params?: array|\stdClass|null * } * * @author Kyrian Obikwelu @@ -42,15 +43,15 @@ public static function fromArray(array $data): self if (!isset($data['method']) || !\is_string($data['method'])) { throw new InvalidArgumentException('Invalid or missing "method" for Notification.'); } - $params = $data['params'] ?? null; + $params = Json::objectsToArrays($data['params'] ?? null); if (null !== $params && !\is_array($params)) { throw new InvalidArgumentException('"params" for Notification must be an array/object or null.'); } $notification = static::fromParams($params); - if (isset($data['params']['_meta'])) { - $notification->meta = $data['params']['_meta']; + if (isset($params['_meta'])) { + $notification->meta = $params['_meta']; } return $notification; diff --git a/src/Schema/JsonRpc/Request.php b/src/Schema/JsonRpc/Request.php index afdf9e977..53976ba97 100644 --- a/src/Schema/JsonRpc/Request.php +++ b/src/Schema/JsonRpc/Request.php @@ -12,13 +12,14 @@ namespace Mcp\Schema\JsonRpc; use Mcp\Exception\InvalidArgumentException; +use Mcp\JsonRpc\Json; /** * @phpstan-type RequestData array{ * jsonrpc: string, * id: string|int, * method: string, - * params?: array, + * params?: array|\stdClass, * } * * @author Kyrian Obikwelu @@ -49,7 +50,7 @@ public static function fromArray(array $data): static } $params = $data['params'] ?? null; if ($params instanceof \stdClass) { - $params = (array) $params; + $params = static::paramsToArray($params); } if (null !== $params && !\is_array($params)) { throw new InvalidArgumentException('"params" for Request must be an array/object or null.'); @@ -58,8 +59,8 @@ public static function fromArray(array $data): static $request = static::fromParams($params); $request->id = $data['id']; - if (isset($data['params']['_meta'])) { - $meta = $data['params']['_meta']; + if (isset($params['_meta'])) { + $meta = $params['_meta']; if ($meta instanceof \stdClass) { $meta = (array) $meta; } @@ -71,6 +72,15 @@ public static function fromArray(array $data): static return $request; } + /** + * @return array + */ + protected static function paramsToArray(\stdClass $params): array + { + /* @var array */ + return Json::objectsToArrays($params); + } + /** * @param array|null $params */ diff --git a/src/Schema/Request/CallToolRequest.php b/src/Schema/Request/CallToolRequest.php index 29754663f..86270aa04 100644 --- a/src/Schema/Request/CallToolRequest.php +++ b/src/Schema/Request/CallToolRequest.php @@ -23,7 +23,8 @@ final class CallToolRequest extends Request { /** * @param string $name the name of the tool to invoke - * @param array $arguments the arguments to pass to the tool + * @param array $arguments the arguments to pass to the tool; when decoded from JSON, + * nested objects are `\stdClass` and nested arrays are lists */ public function __construct( public readonly string $name, @@ -36,36 +37,44 @@ public static function getMethod(): string return 'tools/call'; } + protected static function paramsToArray(\stdClass $params): array + { + $array = parent::paramsToArray($params); + + // Arguments are free-form JSON validated against a schema, so `{}` and `[]` must stay apart. + if (isset($params->arguments)) { + $array['arguments'] = $params->arguments; + } + + return $array; + } + protected static function fromParams(?array $params): static { if (!isset($params['name']) || !\is_string($params['name'])) { throw new InvalidArgumentException('Missing or invalid "name" parameter for tools/call.'); } - $arguments = $params['arguments'] ?? []; - - if ($arguments instanceof \stdClass) { - $arguments = (array) $arguments; - } + $arguments = \array_key_exists('arguments', $params) ? $params['arguments'] : new \stdClass(); - if (!\is_array($arguments)) { - throw new InvalidArgumentException('Parameter "arguments" must be an array.'); + if (\is_array($arguments) && array_is_list($arguments) || !\is_array($arguments) && !$arguments instanceof \stdClass) { + throw new InvalidArgumentException('Parameter "arguments" must be an object.'); } return new self( $params['name'], - $arguments, + (array) $arguments, ); } /** - * @return array{name: string, arguments: array|\stdClass} + * @return array{name: string, arguments: \stdClass} */ protected function getParams(): array { return [ 'name' => $this->name, - 'arguments' => $this->arguments ?: new \stdClass(), + 'arguments' => (object) $this->arguments, ]; } } diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 0d13f9c9c..a0d1f5a80 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -80,8 +80,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er } $inputSchema = $reference->tool->inputSchema; - // Arguments are always a JSON object, but an empty one decodes to `[]`. - $validationErrors = $this->schemaValidator->validateAgainstJsonSchema([] === $arguments ? new \stdClass() : $arguments, $inputSchema); + $validationErrors = $this->schemaValidator->validateAgainstJsonSchema((object) $arguments, $inputSchema); if (!empty($validationErrors)) { $summaryMessage = "Invalid parameters for tool '{$toolName}': ".self::summarizeValidationErrors($validationErrors); diff --git a/src/Server/Stateless/OriginatingRequestBinder.php b/src/Server/Stateless/OriginatingRequestBinder.php index 1acd267fd..af2db2100 100644 --- a/src/Server/Stateless/OriginatingRequestBinder.php +++ b/src/Server/Stateless/OriginatingRequestBinder.php @@ -59,8 +59,8 @@ public function __construct( public function bind(Request $request, ?AccessToken $accessToken): string { [$target, $arguments] = match (true) { - $request instanceof CallToolRequest => [$request->name, $request->arguments], - $request instanceof GetPromptRequest => [$request->name, $request->arguments ?? []], + $request instanceof CallToolRequest => [$request->name, self::canonical($request->arguments, true)], + $request instanceof GetPromptRequest => [$request->name, self::canonical($request->arguments ?? [])], $request instanceof ReadResourceRequest => [$request->uri, []], default => [null, []], }; @@ -68,7 +68,7 @@ public function bind(Request $request, ?AccessToken $accessToken): string return self::encode([ 'method' => $request::getMethod(), 'target' => $target, - 'arguments' => hash('sha256', self::encode(self::canonical($arguments))), + 'arguments' => hash('sha256', self::encode($arguments)), 'principal' => null === $accessToken ? null : $this->principal($accessToken), ]); } @@ -112,19 +112,23 @@ private static function encode(mixed $value): string * and the handler see: a map with numeric keys out of order, e.g. * `{"1": "B", "0": "A"}`, must not sort into the list `["A", "B"]`. Not * cast to an object, as `json_encode()` drops object members whose name - * starts with NUL. The arguments are decoded into PHP arrays, - * so a numeric-key object whose members arrive in list order is a list - * to the whole SDK and binds as one. Numbers bind by value: `1` and `1.0` + * starts with NUL. A `\stdClass` is a map whatever its keys, as tool + * arguments keep their JSON types. Numbers bind by value: `1` and `1.0` * encode alike, so a client re-encoding a number does not break its retry. */ - private static function canonical(mixed $value): mixed + private static function canonical(mixed $value, bool $isMap = false): mixed { + if ($value instanceof \stdClass) { + $value = get_object_vars($value); + $isMap = true; + } + if (!\is_array($value)) { return $value; } - if (array_is_list($value)) { - return array_map(self::canonical(...), $value); + if (!$isMap && array_is_list($value)) { + return array_map(static fn (mixed $item): mixed => self::canonical($item), $value); } ksort($value, \SORT_STRING); diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index f85583596..6e8e5137b 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -268,7 +268,7 @@ private function answer(string $body, array $headers, bool $headerLayer, ?Access // plain result even to a client that would read a stream. $wantsStream = self::DISCOVER_METHOD !== $method && (!$headerLayer || self::acceptsEventStream($headers)); - return $this->dispatch($method, $decoded, $meta, $id, $wantsStream, $accessToken); + return $this->dispatch($method, $params, $body, $meta, $id, $wantsStream, $accessToken); } /** @@ -452,12 +452,12 @@ private static function tagWithSubscription(Notification $notification, string|i } /** - * @param array $decoded + * @param array|null $params */ - private function dispatch(string $method, array $decoded, RequestMeta $meta, string|int|null $id, bool $wantsStream = false, ?AccessToken $accessToken = null): StatelessResult + private function dispatch(string $method, ?array $params, string $body, RequestMeta $meta, string|int|null $id, bool $wantsStream = false, ?AccessToken $accessToken = null): StatelessResult { try { - $request = $this->messageFactory->create(json_encode($decoded, \JSON_THROW_ON_ERROR)); + $request = $this->messageFactory->create($body); } catch (InvalidInputMessageException $e) { // An unknown method (-32601) tells the client to stop asking; a malformed request (-32600) does not. $unknownMethod = \sprintf('Unknown method "%s".', $method) === $e->getMessage(); @@ -519,7 +519,7 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str } try { - $input = $this->liftInputContext($decoded['params'] ?? null, $requestStateCodec); + $input = $this->liftInputContext($params, $requestStateCodec); } catch (RequestStateException $e) { // Invalid params, not an authorization failure: the client only // echoes what it was given, and the reason stays out of the answer. diff --git a/tests/Integration/Fixture/request_state.php b/tests/Integration/Fixture/request_state.php index 5aa7d93fd..187a9ec69 100644 --- a/tests/Integration/Fixture/request_state.php +++ b/tests/Integration/Fixture/request_state.php @@ -23,21 +23,14 @@ use Mcp\Schema\Elicitation\ElicitationSchema; use Mcp\Schema\Elicitation\StringSchemaDefinition; use Mcp\Schema\Enum\Role; -use Mcp\Schema\JsonRpc\Request; -use Mcp\Schema\JsonRpc\Response; -use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server; use Mcp\Server\Authorization\AccessToken; -use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\RequestContext; use Mcp\Server\Session\FileSessionStore; -use Mcp\Server\Session\SessionInterface; -use Mcp\Server\Stateless\InputContext; use Mcp\Server\Stateless\OriginatingRequestBinder; -use Mcp\Server\Stateless\RequestStateCodec; use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware; use Mcp\Server\Transport\Http\Middleware\PassthroughMiddleware; use Mcp\Server\Transport\Http\OAuth\AuthorizationResult; @@ -107,29 +100,6 @@ static function (RequestContext $context, int $amount) use ($approver): string { name: 'report', description: 'Reports progress before the two asks, so its rounds answer as a stream.', ) - ->addRequestHandler(new class implements RequestHandlerInterface { - public function supports(Request $request): bool - { - return $request instanceof CallToolRequest && 'raw' === $request->name; - } - - // Skips schema validation, which refuses member names only a handler like this one gets to see. - public function handle(Request $request, SessionInterface $session): Response - { - assert($request instanceof CallToolRequest); - $input = $session->get(InputContext::class); - $codec = $session->get(RequestStateCodec::class); - assert($codec instanceof RequestStateCodec); - - if ($input instanceof InputContext && true === ($input->requestState()['asked'] ?? null)) { - return new Response($request->getId(), new CallToolResult([new TextContent((string) json_encode($request->arguments['options'] ?? null))])); - } - - $schema = new ElicitationSchema(['v' => new StringSchemaDefinition('Value')], ['v']); - - return new Response($request->getId(), new InputRequiredResult(['v' => new ElicitRequest('Sure?', $schema)], requestState: $codec->mint(['asked' => true]))); - } - }) ->addPrompt( static fn (RequestContext $context, string $topic): array => [new PromptMessage(Role::User, new TextContent(sprintf('brief on %s, approved by %s', $topic, $approver($context))))], name: 'brief', diff --git a/tests/Integration/RequestStateBindingTest.php b/tests/Integration/RequestStateBindingTest.php index 462949673..d6c6de2e6 100644 --- a/tests/Integration/RequestStateBindingTest.php +++ b/tests/Integration/RequestStateBindingTest.php @@ -88,11 +88,6 @@ public static function provideReplays(): iterable 'alice', self::tool('inspect', ['options' => ['B', 'A']]), '["B","A"], approved by alice', ]; - yield 'an approval on other values of members named with a leading NUL' => [ - 'alice', self::tool('raw', ['options' => ['visible' => true, "\0mode" => 'preview']]), - 'alice', self::tool('raw', ['options' => ['visible' => true, "\0mode" => 'execute']]), - '{"visible":true,"\u0000mode":"execute"}', - ]; yield 'alice\'s approval under a token of another issuer' => [ 'alice', $transfer, 'alice-other-issuer', $transfer, @@ -190,11 +185,6 @@ public static function provideEncodings(): iterable self::tool('inspect', ['options' => ['trip' => ['from' => 'HAM', 'to' => 'BER']]]), '{"trip":{"from":"HAM","to":"BER"}}, approved by alice', ]; - yield 'a member named with a leading NUL' => [ - self::tool('raw', ['options' => ['visible' => true, "\0mode" => 'preview']]), - self::tool('raw', ['options' => ["\0mode" => 'preview', 'visible' => true]]), - '{"\u0000mode":"preview","visible":true}', - ]; } /** diff --git a/tests/Integration/StatelessLifecycleTest.php b/tests/Integration/StatelessLifecycleTest.php index 97db97a7c..b0ac11b98 100644 --- a/tests/Integration/StatelessLifecycleTest.php +++ b/tests/Integration/StatelessLifecycleTest.php @@ -91,7 +91,7 @@ public function testInitializeIsRefused(): void #[TestDox('a request whose header contradicts its body is refused with -32020')] public function testHeaderMismatchIsRefused(): void { - $answer = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => []], name: 'something_else'); + $answer = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => new \stdClass()], name: 'something_else'); $this->assertSame(-32020, $answer['error']['code']); } @@ -108,7 +108,7 @@ public function testUnsupportedVersion(): void #[TestDox('a multi round-trip tool asks, then completes on the retry')] public function testMultiRoundTrip(): void { - $asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; + $asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => new \stdClass()], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; $this->assertSame('input_required', $asked['resultType']); $this->assertSame('elicitation/create', $asked['inputRequests']['who']['method']); @@ -119,7 +119,7 @@ public function testMultiRoundTrip(): void $done = $this->call('tools/call', [ 'name' => 'greet', - 'arguments' => [], + 'arguments' => new \stdClass(), 'requestState' => $asked['requestState'], 'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Ada']]], ], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; @@ -131,13 +131,13 @@ public function testMultiRoundTrip(): void #[TestDox('a tampered requestState is refused')] public function testTamperedRequestStateIsRefused(): void { - $asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; + $asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => new \stdClass()], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; [$body] = explode('.', $asked['requestState']); $answer = $this->call('tools/call', [ 'name' => 'greet', - 'arguments' => [], + 'arguments' => new \stdClass(), 'requestState' => $body.'.'.strtr(base64_encode('forged'), '+/', '-_'), 'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Mallory']]], ], name: 'greet', capabilities: ['elicitation' => new \stdClass()]); @@ -148,7 +148,7 @@ public function testTamperedRequestStateIsRefused(): void #[TestDox('asking a client that declared no elicitation is refused with -32021')] public function testUndeclaredCapabilityIsRefused(): void { - $answer = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet'); + $answer = $this->call('tools/call', ['name' => 'greet', 'arguments' => new \stdClass()], name: 'greet'); $this->assertSame(-32021, $answer['error']['code']); $this->assertArrayHasKey('elicitation', $answer['error']['data']['requiredCapabilities']); diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index 28413096a..f1a82fc6b 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -445,7 +445,7 @@ public function testGeneratesCorrectSchemaForArrayTypeDeclarations(): void $this->assertSame('{}', json_encode($schema['properties']['genericArray']['items'])); $this->assertEquals(['type' => 'array', 'description' => 'Array of strings', 'items' => ['type' => 'string']], $schema['properties']['stringArray']); $this->assertEquals(['type' => 'array', 'description' => 'Array of integers', 'items' => ['type' => 'integer']], $schema['properties']['intArray']); - $this->assertEquals(['type' => 'array', 'description' => 'Mixed array map', 'items' => new \stdClass()], $schema['properties']['mixedMap']); + $this->assertEquals(['type' => 'object', 'description' => 'Mixed array map'], $schema['properties']['mixedMap']); $this->assertArrayHasKey('type', $schema['properties']['objectLikeArray']); $this->assertEquals('object', $schema['properties']['objectLikeArray']['type']); $this->assertArrayHasKey('properties', $schema['properties']['objectLikeArray']); @@ -685,4 +685,46 @@ public function testGenerateOutputSchemaForComplexNestedSchema(): void 'additionalProperties' => true, ], $schema); } + + public function testEmptyDefaultOfAMapIsAnObject(): void + { + $handler = new class { + /** + * @param array $map + * @param array|null $nullableMap + * @param array $mapOrList + * @param string[] $list + */ + public function handle(array $map = [], ?array $nullableMap = [], array $mapOrList = [], array $list = []): void + { + } + }; + + $properties = $this->schemaGenerator->generate(new \ReflectionMethod($handler, 'handle'))['properties']; + + $this->assertSame('{}', json_encode($properties['map']['default'])); + $this->assertSame('{}', json_encode($properties['nullableMap']['default'])); + $this->assertSame('[]', json_encode($properties['mapOrList']['default'])); + $this->assertSame('[]', json_encode($properties['list']['default'])); + } + + public function testListOfGenericArraysIsAList(): void + { + $handler = new class { + /** + * @param array[] $maps + * @param array[]|null $nullableMaps + * @param array[] $lists + */ + public function handle($maps, $nullableMaps, $lists): void + { + } + }; + + $properties = $this->schemaGenerator->generate(new \ReflectionMethod($handler, 'handle'))['properties']; + + $this->assertSame('array', $properties['maps']['type']); + $this->assertSame(['array', 'null'], $properties['nullableMaps']['type']); + $this->assertSame('array', $properties['lists']['type']); + } } diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php index 7e9ce83a5..298cd38b8 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -155,6 +155,25 @@ public function search(?string $category = 'books'): string $this->assertSame("'books'", (new ReferenceHandler())->handle($reference, $context)); } + public function testParameterTypeDecidesHowJsonObjectsAreDelivered(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $value = (object) ['a' => new \stdClass(), 'list' => [(object) ['b' => 1]]]; + $context = ['_session' => $session, '_request' => new PingRequest(), 'value' => $value]; + $plain = ['a' => [], 'list' => [['b' => 1]]]; + $handler = new ReferenceHandler(); + + $this->assertSame($plain, $handler->handle(new ElementReference(static fn (array $value): array => $value), $context)); + $this->assertSame($plain, $handler->handle(new ElementReference(static fn (array|string $value): array|string => $value), $context)); + $this->assertSame($plain, $handler->handle(new ElementReference(static fn (iterable $value): iterable => $value), $context)); + $this->assertSame($value, $handler->handle(new ElementReference(static fn (\stdClass $value): \stdClass => $value), $context)); + $this->assertSame($value, $handler->handle(new ElementReference(static fn (object $value): object => $value), $context)); + $this->assertSame($value, $handler->handle(new ElementReference(static fn (array|\stdClass $value): array|\stdClass => $value), $context)); + $this->assertSame($value, $handler->handle(new ElementReference(static fn (mixed $value): mixed => $value), $context)); + $this->assertSame($value, $handler->handle(new ElementReference(static fn ($value) => $value), $context)); + } + public function testHandleKeepsTheDefaultOfANonNullableParameterOnAnExplicitNull(): void { $session = $this->createMock(SessionInterface::class); diff --git a/tests/Unit/Client/Stateless/HeaderFactoryTest.php b/tests/Unit/Client/Stateless/HeaderFactoryTest.php index 5f2843151..4aae8071c 100644 --- a/tests/Unit/Client/Stateless/HeaderFactoryTest.php +++ b/tests/Unit/Client/Stateless/HeaderFactoryTest.php @@ -40,7 +40,7 @@ public function testStandardHeaders(): void #[TestDox('names the subject of a request that addresses one')] public function testNameHeader(): void { - $payload = ['method' => 'tools/call', 'params' => ['name' => 'search', 'arguments' => []]]; + $payload = ['method' => 'tools/call', 'params' => ['name' => 'search', 'arguments' => new \stdClass()]]; $this->assertSame('search', $this->headersFor($payload)['Mcp-Name']); $this->assertNull($this->validate($payload)); diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index febebd801..50edfb211 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -17,8 +17,10 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\CancelledNotification; use Mcp\Schema\Notification\InitializedNotification; +use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\GetPromptRequest; +use Mcp\Schema\Request\InitializeRequest; use Mcp\Schema\Request\PingRequest; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -37,6 +39,48 @@ protected function setUp(): void ]); } + public function testToolArgumentsKeepTheirJsonTypes(): void + { + $message = MessageFactory::make()->create('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"t","arguments":{"object":{},"list":[],"numeric":{"0":"x"},"nested":[{"a":{}}]}}}'); + + $this->assertInstanceOf(CallToolRequest::class, $message); + $this->assertEquals(new \stdClass(), $message->arguments['object']); + $this->assertSame([], $message->arguments['list']); + $this->assertEquals((object) ['0' => 'x'], $message->arguments['numeric']); + $this->assertEquals([(object) ['a' => new \stdClass()]], $message->arguments['nested']); + } + + public function testRootArgumentsWithNumericKeysStayAnObjectOnTheWire(): void + { + $message = MessageFactory::make()->create('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"t","arguments":{"0":"x"}}}'); + + $this->assertInstanceOf(CallToolRequest::class, $message); + $this->assertStringContainsString('"arguments":{"0":"x"}', json_encode($message, \JSON_THROW_ON_ERROR)); + } + + public function testOtherParamsAreDecodedToArrays(): void + { + $message = MessageFactory::make()->create('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"x","version":"1"}}}'); + + $this->assertInstanceOf(InitializeRequest::class, $message); + } + + public function testEmptyObjectIsRejected(): void + { + $this->expectException(InvalidInputMessageException::class); + $this->expectExceptionMessage('missing "method"'); + + $this->factory->create('{}'); + } + + public function testAnObjectWithNumericKeysIsNotABatch(): void + { + $this->expectException(InvalidInputMessageException::class); + $this->expectExceptionMessage('missing "method"'); + + $this->factory->create('{"0":{"jsonrpc":"2.0","id":1,"method":"ping"}}'); + } + public function testCreateRequestWithIntegerId(): void { $json = '{"jsonrpc": "2.0", "method": "prompts/get", "params": {"name": "create_story"}, "id": 123}'; @@ -342,7 +386,6 @@ public static function provideInputsThatAreNotOneObject(): iterable yield 'integer' => ['5']; yield 'string' => ['"hello"']; yield 'null' => ['null']; - yield 'empty object' => ['{}']; yield 'empty array' => ['[]']; yield 'list of scalars' => ['[1, 2]']; yield 'batch' => ['[{"jsonrpc": "2.0", "method": "ping", "id": 1}, {"jsonrpc": "2.0", "method": "ping", "id": 2}]']; diff --git a/tests/Unit/Schema/Request/CallToolRequestTest.php b/tests/Unit/Schema/Request/CallToolRequestTest.php new file mode 100644 index 000000000..795adf43f --- /dev/null +++ b/tests/Unit/Schema/Request/CallToolRequestTest.php @@ -0,0 +1,75 @@ +assertSame([], self::request(['name' => 'weather'])->arguments); + } + + public function testEmptyArgumentsSerializeAsAnObject(): void + { + $json = json_encode(self::request(['name' => 'weather', 'arguments' => new \stdClass()]), \JSON_THROW_ON_ERROR); + + $this->assertStringContainsString('"arguments":{}', $json); + } + + public function testAssociativeArgumentsAreAccepted(): void + { + $this->assertSame(['city' => 'Berlin'], self::request(['name' => 'weather', 'arguments' => ['city' => 'Berlin']])->arguments); + } + + public function testObjectParamsKeepTheJsonTypesOfArgumentsOnly(): void + { + $arguments = (object) ['filter' => new \stdClass(), 'ids' => []]; + $request = self::request((object) ['name' => 'weather', 'arguments' => $arguments, '_meta' => (object) ['trace' => (object) []]]); + + $this->assertEquals(new \stdClass(), $request->arguments['filter']); + $this->assertSame([], $request->arguments['ids']); + $this->assertSame(['trace' => []], $request->getMeta()); + } + + #[DataProvider('invalidArguments')] + public function testArgumentsMustBeAnObject(mixed $arguments): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Parameter "arguments" must be an object.'); + + self::request(['name' => 'weather', 'arguments' => $arguments]); + } + + /** + * @return iterable + */ + public static function invalidArguments(): iterable + { + yield 'empty list' => [[]]; + yield 'list' => [['Berlin']]; + yield 'string' => ['Berlin']; + yield 'null' => [null]; + } + + /** + * @param array|\stdClass $params + */ + private static function request(array|\stdClass $params): CallToolRequest + { + return CallToolRequest::fromArray(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => $params]); + } +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index e3c25fa85..5f2dc3de3 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -568,7 +568,7 @@ private function callTool(Server $server, string $toolName): mixed 'jsonrpc' => '2.0', 'method' => 'tools/call', 'id' => 'test-1', - 'params' => ['name' => $toolName, 'arguments' => []], + 'params' => ['name' => $toolName, 'arguments' => new \stdClass()], ]); $session = $this->createStub(SessionInterface::class); diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 300cca442..b4476b3fd 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -988,7 +988,7 @@ private function createCallToolRequest(string $name, array $arguments): CallTool 'id' => 'test-request-'.uniqid(), 'params' => [ 'name' => $name, - 'arguments' => $arguments, + 'arguments' => (object) $arguments, ], ]); } diff --git a/tests/Unit/Server/RequestContextTest.php b/tests/Unit/Server/RequestContextTest.php index fcbe65d4d..d7848f6dc 100644 --- a/tests/Unit/Server/RequestContextTest.php +++ b/tests/Unit/Server/RequestContextTest.php @@ -81,7 +81,7 @@ private function createRequest(?array $meta = null): CallToolRequest 'jsonrpc' => '2.0', 'method' => CallToolRequest::getMethod(), 'id' => 'test-request', - 'params' => ['name' => 'test_tool', 'arguments' => []], + 'params' => ['name' => 'test_tool', 'arguments' => new \stdClass()], ]); return null === $meta ? $request : $request->withMeta($meta); diff --git a/tests/Unit/Server/Stateless/OriginatingRequestBinderTest.php b/tests/Unit/Server/Stateless/OriginatingRequestBinderTest.php index c5ee21f83..bef91d029 100644 --- a/tests/Unit/Server/Stateless/OriginatingRequestBinderTest.php +++ b/tests/Unit/Server/Stateless/OriginatingRequestBinderTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Server\Stateless; +use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\GetPromptRequest; @@ -124,6 +125,35 @@ public function testArgumentsTheHandlerSeesDifferentlyBindElsewhere(string $firs ); } + /** + * @return iterable + */ + public static function decodedArguments(): iterable + { + yield 'nested member order' => ['{"trip": {"to": "BER", "from": "HAM"}}', '{"trip": {"from": "HAM", "to": "BER"}}', true]; + yield 'root member order' => ['{"to": "BER", "from": "HAM"}', '{"from": "HAM", "to": "BER"}', true]; + yield 'numeric root member order' => ['{"0": "A", "1": "B"}', '{"1": "B", "0": "A"}', true]; + yield 'numeric nested member order' => ['{"items": {"0": "A", "1": "B"}}', '{"items": {"1": "B", "0": "A"}}', true]; + yield 'empty object and empty array' => ['{"items": {}}', '{"items": []}', false]; + yield 'numeric-key object and list' => ['{"items": {"0": "A"}}', '{"items": ["A"]}', false]; + } + + #[DataProvider('decodedArguments')] + #[TestDox('arguments bind as they arrive from the wire: $_dataName')] + public function testArgumentsDecodedFromTheWire(string $first, string $retry, bool $same): void + { + $binder = new OriginatingRequestBinder(); + $bind = static function (string $arguments) use ($binder): string { + $json = \sprintf('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book","arguments":%s}}', $arguments); + $request = MessageFactory::make()->create($json); + self::assertInstanceOf(CallToolRequest::class, $request); + + return $binder->bind($request, self::token()); + }; + + $this->assertSame($same, $bind($first) === $bind($retry)); + } + #[TestDox('a token without a subject fails closed: no two requests bind to the same value')] public function testTokenWithoutSubjectBindsToAValueOfItsOwn(): void { diff --git a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php index 38931be27..94474f8d0 100644 --- a/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php +++ b/tests/Unit/Server/Stateless/StandardHeaderValidatorTest.php @@ -35,7 +35,7 @@ public function testConsistentRequestPasses(): void { $this->assertNull($this->validator->validate( 'tools/call', - ['name' => 'do_thing', 'arguments' => []], + ['name' => 'do_thing', 'arguments' => new \stdClass()], ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'do_thing'], )); } @@ -286,7 +286,7 @@ public function testNestedMirroredArgumentIsChecked(): void // Absent at that path, so no header is expected. $this->assertNull($validator->validate( 'tools/call', - ['name' => 'mirrored', 'arguments' => []], + ['name' => 'mirrored', 'arguments' => new \stdClass()], ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored'], )); } @@ -298,7 +298,7 @@ public function testMirroredHeaderWithoutArgumentIsRejected(): void $this->assertStringContainsString('Mcp-Param-Retries header is present', (string) $validator->validate( 'tools/call', - ['name' => 'mirrored', 'arguments' => []], + ['name' => 'mirrored', 'arguments' => new \stdClass()], ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Retries' => '3'], )); @@ -316,7 +316,7 @@ public function testUnknownParamHeaderIsIgnored(): void $this->assertNull($validator->validate( 'tools/call', - ['name' => 'mirrored', 'arguments' => []], + ['name' => 'mirrored', 'arguments' => new \stdClass()], ['Mcp-Method' => 'tools/call', 'Mcp-Name' => 'mirrored', 'Mcp-Param-Unknown' => 'x'], )); } diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 824a8a12c..058eb7ce5 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -270,7 +270,7 @@ public function testClientCapabilitiesReachTheGateway(): void $answer = self::call( self::protocol(), 'tools/call', - ['name' => 'probe_capabilities', 'arguments' => []], + ['name' => 'probe_capabilities', 'arguments' => new \stdClass()], ['Mcp-Name' => 'probe_capabilities'], ['roots' => new \stdClass(), 'elicitation' => ['url' => new \stdClass()], 'sampling' => ['tools' => new \stdClass()]], ); @@ -288,7 +288,7 @@ public function testEmptyCapabilitiesReportNone(): void $answer = self::call( self::protocol(), 'tools/call', - ['name' => 'probe_capabilities', 'arguments' => []], + ['name' => 'probe_capabilities', 'arguments' => new \stdClass()], ['Mcp-Name' => 'probe_capabilities'], ); @@ -369,7 +369,7 @@ private static function callStreaming(StatelessProtocol $protocol, string $tool, 'method' => 'tools/call', 'params' => [ 'name' => $tool, - 'arguments' => [], + 'arguments' => new \stdClass(), '_meta' => [ RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), @@ -419,7 +419,7 @@ public function testNotificationsAreDroppedWithoutAnAcceptingClient(): void 'method' => 'tools/call', 'params' => [ 'name' => 'progress_tool', - 'arguments' => [], + 'arguments' => new \stdClass(), '_meta' => [ RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), @@ -522,7 +522,7 @@ public function testGatewayElicitationBecomesAnAsk(): void $answer = self::call( self::protocol(), 'tools/call', - ['name' => 'elicits_directly', 'arguments' => []], + ['name' => 'elicits_directly', 'arguments' => new \stdClass()], ['Mcp-Name' => 'elicits_directly'], ['elicitation' => new \stdClass()], ); @@ -543,7 +543,7 @@ public function testGatewayElicitationResumesOnRetry(): void 'tools/call', [ 'name' => 'elicits_directly', - 'arguments' => [], + 'arguments' => new \stdClass(), 'inputResponses' => ['elicitation_1' => ['action' => 'accept', 'content' => ['n' => 'ada']]], ], ['Mcp-Name' => 'elicits_directly'], @@ -575,7 +575,7 @@ public function testUndeclaredGatewayElicitationIsRefused(): void $answer = self::call( self::protocol(), 'tools/call', - ['name' => 'elicits_directly', 'arguments' => []], + ['name' => 'elicits_directly', 'arguments' => new \stdClass()], ['Mcp-Name' => 'elicits_directly'], ); @@ -633,7 +633,7 @@ private static function book(StatelessProtocol $protocol, array $params = []): a return self::call( $protocol, 'tools/call', - ['name' => 'books_flight', 'arguments' => [], ...$params], + ['name' => 'books_flight', 'arguments' => new \stdClass(), ...$params], ['Mcp-Name' => 'books_flight'], ['elicitation' => new \stdClass()], ); @@ -897,7 +897,7 @@ public function testMalformedAnswerIsAskedAgain(): void 'tools/call', [ 'name' => 'elicits_directly', - 'arguments' => [], + 'arguments' => new \stdClass(), // Accepted, but a form-mode acceptance carries content. 'inputResponses' => ['elicitation_1' => ['action' => 'accept']], ], @@ -918,7 +918,7 @@ public function testTraceContextReachesTheHandler(): void 'tools/call', [ 'name' => 'probe_trace', - 'arguments' => [], + 'arguments' => new \stdClass(), '_meta' => [ RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), @@ -941,7 +941,7 @@ public function testHttpTraceHeadersReachTheHandler(): void $answer = self::call( self::protocol(), 'tools/call', - ['name' => 'probe_trace', 'arguments' => []], + ['name' => 'probe_trace', 'arguments' => new \stdClass()], [ 'Mcp-Name' => 'probe_trace', 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01', @@ -963,7 +963,7 @@ public function testMetaTraceContextWinsOverHeader(): void 'tools/call', [ 'name' => 'probe_trace', - 'arguments' => [], + 'arguments' => new \stdClass(), '_meta' => [ RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), @@ -1026,7 +1026,7 @@ public function testUnknownToolUsesInvalidParams(): void $answer = self::call( self::protocol(), 'tools/call', - ['name' => 'no_such_tool', 'arguments' => []], + ['name' => 'no_such_tool', 'arguments' => new \stdClass()], ['Mcp-Name' => 'no_such_tool'], ); @@ -1039,7 +1039,7 @@ public function testUnknownPromptUsesInvalidParams(): void $answer = self::call( self::protocol(), 'prompts/get', - ['name' => 'no_such_prompt', 'arguments' => []], + ['name' => 'no_such_prompt', 'arguments' => new \stdClass()], ['Mcp-Name' => 'no_such_prompt'], ); @@ -1053,8 +1053,8 @@ public function testReservedCodesAreNeverEmitted(): void $answers = [ self::call($protocol, 'resources/read', ['uri' => 'test://absent'], ['Mcp-Name' => 'test://absent']), - self::call($protocol, 'tools/call', ['name' => 'nope', 'arguments' => []], ['Mcp-Name' => 'nope']), - self::call($protocol, 'prompts/get', ['name' => 'nope', 'arguments' => []], ['Mcp-Name' => 'nope']), + self::call($protocol, 'tools/call', ['name' => 'nope', 'arguments' => new \stdClass()], ['Mcp-Name' => 'nope']), + self::call($protocol, 'prompts/get', ['name' => 'nope', 'arguments' => new \stdClass()], ['Mcp-Name' => 'nope']), ]; foreach ($answers as $answer) { @@ -1107,7 +1107,7 @@ public function testUrlElicitationNeedsItsOwnDeclaration(): void $answer = self::call( self::protocol(), 'tools/call', - ['name' => 'asks_by_url', 'arguments' => []], + ['name' => 'asks_by_url', 'arguments' => new \stdClass()], ['Mcp-Name' => 'asks_by_url'], ['elicitation' => new \stdClass()], ); @@ -1123,7 +1123,7 @@ public function testUrlElicitationPassesWhenDeclared(): void $answer = self::call( self::protocol(), 'tools/call', - ['name' => 'asks_by_url', 'arguments' => []], + ['name' => 'asks_by_url', 'arguments' => new \stdClass()], ['Mcp-Name' => 'asks_by_url'], ['elicitation' => ['url' => new \stdClass()]], ); @@ -1309,7 +1309,7 @@ private static function inlineRequest(string $method, array $params = []): strin public function testInlineMessageNeedsNoHeaders(): void { $protocol = self::protocol(); - $message = self::inlineRequest('tools/call', ['name' => 'plain_tool', 'arguments' => []]); + $message = self::inlineRequest('tools/call', ['name' => 'plain_tool', 'arguments' => new \stdClass()]); // The same message over HTTP is missing headers it has to carry. $this->assertSame(Error::HEADER_MISMATCH, json_decode($protocol->handle($message)->toJson(), true)['error']['code']); @@ -1325,7 +1325,7 @@ public function testInlineProgressIsStreamed(): void { $result = self::protocol()->handleInline(self::inlineRequest('tools/call', [ 'name' => 'progress_tool', - 'arguments' => [], + 'arguments' => new \stdClass(), '_meta' => ['progressToken' => 'tok-1'], ])); @@ -1693,7 +1693,7 @@ public function testElicitationDefaultsToFormMode(): void $answer = self::call( self::protocol(), 'tools/call', - ['name' => 'probe_capabilities', 'arguments' => []], + ['name' => 'probe_capabilities', 'arguments' => new \stdClass()], ['Mcp-Name' => 'probe_capabilities'], ['elicitation' => new \stdClass()], ); @@ -1729,7 +1729,7 @@ public function testRequestAndResponseEventsAreDispatched(): void $answer = self::call( self::protocol(eventDispatcher: $this->eventDispatcher($events)), 'tools/call', - ['name' => 'plain_tool', 'arguments' => []], + ['name' => 'plain_tool', 'arguments' => new \stdClass()], ['Mcp-Name' => 'plain_tool'], ); @@ -1754,7 +1754,7 @@ public function testRequestEventCanReplaceTheRequest(): void $answer = self::call( self::protocol(eventDispatcher: $this->eventDispatcher($events, $listener)), 'tools/call', - ['name' => 'probe_trace', 'arguments' => []], + ['name' => 'probe_trace', 'arguments' => new \stdClass()], ['Mcp-Name' => 'probe_trace'], ); @@ -1774,7 +1774,7 @@ public function testErrorEventIsDispatched(): void $answer = self::call( self::protocol(eventDispatcher: $this->eventDispatcher($events, $listener)), 'tools/call', - ['name' => 'capability_tool', 'arguments' => []], + ['name' => 'capability_tool', 'arguments' => new \stdClass()], ['Mcp-Name' => 'capability_tool'], ); diff --git a/tests/Unit/Server/Transport/StdioDualEraTest.php b/tests/Unit/Server/Transport/StdioDualEraTest.php index 002e13514..6595bd686 100644 --- a/tests/Unit/Server/Transport/StdioDualEraTest.php +++ b/tests/Unit/Server/Transport/StdioDualEraTest.php @@ -75,6 +75,144 @@ public function testBatchIsRefused(): void $this->assertSame([ProtocolVersion::V2026_07_28->value], $lines[1]['result']['supportedVersions']); } + #[DataProvider('toolArguments')] + #[TestDox('tool arguments are validated with their JSON type: $json against $type')] + public function testToolArgumentsKeepTheirJsonType(string $type, string $json, bool $accepted): void + { + $builder = self::builder()->addTool( + static fn (mixed $value = null): string => json_encode($value, \JSON_THROW_ON_ERROR), + name: 'typed', + inputSchema: ['type' => 'object', 'properties' => ['value' => ['type' => $type]], 'required' => ['value']], + ); + $params = ['name' => 'typed', 'arguments' => json_decode($json, flags: \JSON_THROW_ON_ERROR)]; + + foreach (self::eraOpenings($params) as $messages) { + $answer = $this->serve(clone $builder, $messages)[2]; + + if ($accepted) { + $this->assertSame(json_encode(json_decode($json)->value), $answer['result']['content'][0]['text']); + } else { + $this->assertSame(Error::INVALID_PARAMS, $answer['error']['code']); + } + } + } + + /** + * @return iterable + */ + public static function toolArguments(): iterable + { + yield 'empty object as object' => ['object', '{"value":{}}', true]; + yield 'numeric keys as object' => ['object', '{"value":{"0":"x"}}', true]; + yield 'nested empty object as object' => ['object', '{"value":{"a":{}}}', true]; + yield 'empty array as object' => ['object', '{"value":[]}', false]; + yield 'list as object' => ['object', '{"value":["x"]}', false]; + yield 'empty array as array' => ['array', '{"value":[]}', true]; + yield 'list as array' => ['array', '{"value":["x"]}', true]; + yield 'empty object as array' => ['array', '{"value":{}}', false]; + yield 'numeric keys as array' => ['array', '{"value":{"0":"x"}}', false]; + } + + /** + * @param array $valueSchema + * @param array $defs + */ + #[DataProvider('schemaKeywords')] + #[TestDox('schema keywords see the JSON type of the arguments: $json')] + public function testSchemaKeywordsSeeTheJsonType(array $valueSchema, string $json, bool $accepted, array $defs = []): void + { + $inputSchema = ['type' => 'object', 'properties' => ['value' => $valueSchema], 'required' => ['value']]; + if ([] !== $defs) { + $inputSchema['$defs'] = $defs; + } + + $builder = self::builder()->addTool( + static fn (mixed $value = null): string => json_encode($value, \JSON_THROW_ON_ERROR), + name: 'typed', + inputSchema: $inputSchema, + ); + $params = ['name' => 'typed', 'arguments' => json_decode($json, flags: \JSON_THROW_ON_ERROR)]; + + foreach (self::eraOpenings($params) as $messages) { + $answer = $this->serve(clone $builder, $messages)[2]; + + if ($accepted) { + $this->assertSame(json_encode(json_decode($json)->value), $answer['result']['content'][0]['text']); + } else { + $this->assertSame(Error::INVALID_PARAMS, $answer['error']['code']); + } + } + } + + /** + * @return iterable> + */ + public static function schemaKeywords(): iterable + { + $nestedArray = ['type' => 'object', 'properties' => ['list' => ['type' => 'array']]]; + yield 'nested array is an array' => [$nestedArray, '{"value":{"list":[]}}', true]; + yield 'nested object is no array' => [$nestedArray, '{"value":{"list":{}}}', false]; + + $items = ['type' => 'array', 'items' => ['type' => 'object']]; + yield 'array item is an object' => [$items, '{"value":[{}]}', true]; + yield 'array item is no object' => [$items, '{"value":[[]]}', false]; + + $additional = ['type' => 'object', 'additionalProperties' => ['type' => 'object']]; + yield 'additional property is an object' => [$additional, '{"value":{"a":{}}}', true]; + yield 'additional property is no object' => [$additional, '{"value":{"a":[]}}', false]; + + $ref = ['$ref' => '#/$defs/settings']; + $defs = ['settings' => ['type' => 'object']]; + yield 'reference to an object gets an object' => [$ref, '{"value":{}}', true, $defs]; + yield 'reference to an object gets an array' => [$ref, '{"value":[]}', false, $defs]; + + foreach (['oneOf', 'anyOf'] as $keyword) { + $union = [$keyword => [['type' => 'object'], ['type' => 'array']]]; + yield $keyword.' keeps an object' => [$union, '{"value":{}}', true]; + yield $keyword.' keeps an array' => [$union, '{"value":[]}', true]; + + $nonEmptyList = [$keyword => [['type' => 'object'], ['type' => 'array', 'minItems' => 1]]]; + yield $keyword.' with an object branch gets an object' => [$nonEmptyList, '{"value":{}}', true]; + yield $keyword.' with a non-empty list branch gets an empty array' => [$nonEmptyList, '{"value":[]}', false]; + } + } + + #[DataProvider('nonObjectArguments')] + #[TestDox('arguments that are not a JSON object are refused: $json')] + public function testArgumentsMustBeAJsonObject(string $json): void + { + $params = ['name' => 'echo', 'arguments' => json_decode($json, flags: \JSON_THROW_ON_ERROR)]; + + foreach (self::eraOpenings($params) as $messages) { + $this->assertSame(Error::INVALID_REQUEST, $this->serve(self::builder(), $messages)[2]['error']['code']); + } + } + + /** + * @return iterable + */ + public static function nonObjectArguments(): iterable + { + yield 'empty array' => ['[]']; + yield 'list' => ['["x"]']; + yield 'string' => ['"x"']; + yield 'null' => ['null']; + } + + public function testMemberNameStartingWithANulByteIsRefused(): void + { + $params = ['name' => 'echo', 'arguments' => ["\0text" => 'hi']]; + + foreach (self::eraOpenings($params) as $messages) { + $lines = $this->exchange(self::builder(), $messages); + + $error = $lines[\count($lines) - 1]; + + $this->assertSame(Error::INVALID_REQUEST, $error['error']['code']); + $this->assertSame(2, $error['id']); + } + } + #[TestDox('a handshake after a modern opening is refused, naming the modern revisions')] public function testHandshakeAfterModernOpeningIsRefused(): void { @@ -231,7 +369,7 @@ public function testResponseOnModernConnectionIsIgnored(): void public function testModernProgressIsStreamed(): void { $lines = $this->exchange(self::builder(), [ - self::modern(1, 'tools/call', ['name' => 'count', 'arguments' => [], '_meta' => ['progressToken' => 'p']]), + self::modern(1, 'tools/call', ['name' => 'count', 'arguments' => new \stdClass(), '_meta' => ['progressToken' => 'p']]), ]); $this->assertSame(['notifications/progress', 'notifications/progress'], [$lines[0]['method'], $lines[1]['method']]); @@ -243,7 +381,7 @@ public function testModernProgressIsStreamed(): void public function testRequestCompletesBeforeItsCancelIsRead(): void { $lines = $this->exchange(self::builder(), [ - self::modern(1, 'tools/call', ['name' => 'count', 'arguments' => [], '_meta' => ['progressToken' => 'p']]), + self::modern(1, 'tools/call', ['name' => 'count', 'arguments' => new \stdClass(), '_meta' => ['progressToken' => 'p']]), ['jsonrpc' => '2.0', 'method' => 'notifications/cancelled', 'params' => ['requestId' => 1]], ]); @@ -437,6 +575,23 @@ private static function modern(int $id, string $method, array $params = [], ?str return ['jsonrpc' => '2.0', 'id' => $id, 'method' => $method, 'params' => $params]; } + /** + * A `tools/call` with id 2, as each era's client sends it. + * + * @param array $params + * + * @return iterable>> + */ + private static function eraOpenings(array $params): iterable + { + yield 'handshake' => [ + self::initialize(1), + ['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], + ['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => $params], + ]; + yield 'modern' => [self::modern(2, 'tools/call', $params)]; + } + /** * @return array */ From a7501978e59a1eb43dbbd33056dc16b430819c72 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 12:06:57 +0100 Subject: [PATCH 2/6] [Examples] Show JSON object arguments in complex-tool-schema --- docs/examples.md | 2 +- .../complex-tool-schema/McpEventScheduler.php | 26 +++++++++++----- .../Server/Http/HttpComplexToolSchemaTest.php | 14 +++++++++ ...ll-schedule_event_labels_and_metadata.json | 30 +++++++++++++++++++ .../HttpComplexToolSchemaTest-tools_list.json | 13 ++++++++ 5 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_labels_and_metadata.json diff --git a/docs/examples.md b/docs/examples.md index 05af48655..9aaf330d8 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -25,7 +25,7 @@ npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/ser | [`combined-registration`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/combined-registration) | Discovery and manual registration combined, and which wins on conflict | [Registering elements](servers/registration.md) | | [`cached-discovery`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/cached-discovery) | Caching the discovery scan in a PSR-16 cache | [Server builder](run/server-builder.md#discovery-configuration) | | [`custom-dependencies`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-dependencies) | Handlers pulling services from a PSR-11 container | [Server builder](run/server-builder.md#service-dependencies) | -| [`complex-tool-schema`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/complex-tool-schema) | Rich input schemas from typed parameters, PHP enums and defaults | [Schema generation](servers/schemas.md) | +| [`complex-tool-schema`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/complex-tool-schema) | Rich input schemas from typed parameters, PHP enums, defaults and JSON objects | [Schema generation](servers/schemas.md) | | [`schema-showcase`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/schema-showcase) | `#[Schema]` constraint attributes: formats, patterns, ranges | [Schema generation](servers/schemas.md) | | [`env-variables`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/env-variables) | Configuring a server through environment variables | [Server builder](run/server-builder.md) | | [`client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) | Sampling, roots, progress and log messages from inside a handler | [Talking back to the client](handlers/client-communication.md) | diff --git a/examples/server/complex-tool-schema/McpEventScheduler.php b/examples/server/complex-tool-schema/McpEventScheduler.php index 366c687ea..0777b22bb 100644 --- a/examples/server/complex-tool-schema/McpEventScheduler.php +++ b/examples/server/complex-tool-schema/McpEventScheduler.php @@ -27,13 +27,15 @@ public function __construct( * Schedules a new event. * The inputSchema for this tool will reflect all parameter types and defaults. * - * @param string $title the title of the event - * @param string $date the date of the event (YYYY-MM-DD) - * @param EventType $type the type of event - * @param string|null $time the time of the event (HH:MM), optional - * @param EventPriority $priority The priority of the event. Defaults to Normal. - * @param string[]|null $attendees an optional list of attendee email addresses - * @param bool $sendInvites send calendar invites to attendees? Defaults to true if attendees are provided + * @param string $title the title of the event + * @param string $date the date of the event (YYYY-MM-DD) + * @param EventType $type the type of event + * @param string|null $time the time of the event (HH:MM), optional + * @param EventPriority $priority The priority of the event. Defaults to Normal. + * @param string[]|null $attendees an optional list of attendee email addresses + * @param bool $sendInvites send calendar invites to attendees? Defaults to true if attendees are provided + * @param array $labels labels of the event by name + * @param \stdClass|null $metadata free-form data stored with the event, exactly as sent * * @return array confirmation of the scheduled event */ @@ -46,6 +48,8 @@ public function scheduleEvent( EventPriority $priority = EventPriority::Normal, ?array $attendees = null, bool $sendInvites = true, + array $labels = [], + ?\stdClass $metadata = null, ): array { $this->logger->info("Tool 'schedule_event' called", compact('title', 'date', 'type', 'time', 'priority', 'attendees', 'sendInvites')); @@ -60,6 +64,14 @@ public function scheduleEvent( 'invites_will_be_sent' => ($attendees && $sendInvites), ]; + if ([] !== $labels) { + $eventDetails['labels'] = $labels; + } + + if (null !== $metadata) { + $eventDetails['metadata'] = $metadata; + } + // In a real app, this would interact with a calendar service $this->logger->info('Event scheduled', ['details' => $eventDetails]); diff --git a/tests/Interop/Server/Http/HttpComplexToolSchemaTest.php b/tests/Interop/Server/Http/HttpComplexToolSchemaTest.php index f43feb0eb..866e86af8 100644 --- a/tests/Interop/Server/Http/HttpComplexToolSchemaTest.php +++ b/tests/Interop/Server/Http/HttpComplexToolSchemaTest.php @@ -77,6 +77,20 @@ public static function provideMethods(): array ], 'testName' => 'schedule_event_low_priority', ], + 'Schedule Event (Labels and Metadata)' => [ + 'method' => 'tools/call', + 'options' => [ + 'toolName' => 'schedule_event', + 'toolArgs' => [ + 'title' => 'Release Planning', + 'date' => '2024-12-10', + 'type' => 'meeting', + 'labels' => ['team' => 'platform'], + 'metadata' => ['source' => 'crm', 'tags' => [], 'custom' => new \stdClass()], + ], + ], + 'testName' => 'schedule_event_labels_and_metadata', + ], ]; } diff --git a/tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_labels_and_metadata.json b/tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_labels_and_metadata.json new file mode 100644 index 000000000..514a39a47 --- /dev/null +++ b/tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_labels_and_metadata.json @@ -0,0 +1,30 @@ +{ + "content": [ + { + "type": "text", + "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Release Planning\\\" scheduled successfully for \\\"2024-12-10\\\".\",\n \"event_details\": {\n \"title\": \"Release Planning\",\n \"date\": \"2024-12-10\",\n \"type\": \"meeting\",\n \"time\": \"All day\",\n \"priority\": \"Normal\",\n \"attendees\": [],\n \"invites_will_be_sent\": false,\n \"labels\": {\n \"team\": \"platform\"\n },\n \"metadata\": {\n \"source\": \"crm\",\n \"tags\": [],\n \"custom\": {}\n }\n }\n}" + } + ], + "structuredContent": { + "success": true, + "message": "Event \"Release Planning\" scheduled successfully for \"2024-12-10\".", + "event_details": { + "title": "Release Planning", + "date": "2024-12-10", + "type": "meeting", + "time": "All day", + "priority": "Normal", + "attendees": [], + "invites_will_be_sent": false, + "labels": { + "team": "platform" + }, + "metadata": { + "source": "crm", + "tags": [], + "custom": {} + } + } + }, + "isError": false +} diff --git a/tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json b/tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json index 0e0bbe93d..bff62c43c 100644 --- a/tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json +++ b/tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json @@ -57,6 +57,19 @@ "type": "boolean", "description": "send calendar invites to attendees? Defaults to true if attendees are provided", "default": true + }, + "labels": { + "type": "object", + "description": "labels of the event by name", + "default": {} + }, + "metadata": { + "type": [ + "null", + "object" + ], + "description": "free-form data stored with the event, exactly as sent", + "default": null } }, "required": [ From 0f6c6ffa1868ba2f979ca840921571ad3f5472c7 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 12:09:33 +0100 Subject: [PATCH 3/6] [Examples] Send an object carrying data in the complex-tool-schema interop case --- tests/Interop/Server/Http/HttpComplexToolSchemaTest.php | 2 +- ...Test-tools_call-schedule_event_labels_and_metadata.json | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Interop/Server/Http/HttpComplexToolSchemaTest.php b/tests/Interop/Server/Http/HttpComplexToolSchemaTest.php index 866e86af8..a70cea6f1 100644 --- a/tests/Interop/Server/Http/HttpComplexToolSchemaTest.php +++ b/tests/Interop/Server/Http/HttpComplexToolSchemaTest.php @@ -86,7 +86,7 @@ public static function provideMethods(): array 'date' => '2024-12-10', 'type' => 'meeting', 'labels' => ['team' => 'platform'], - 'metadata' => ['source' => 'crm', 'tags' => [], 'custom' => new \stdClass()], + 'metadata' => ['source' => 'crm', 'tags' => [], 'rooms' => (object) ['0' => 'Berlin', '1' => 'Hamburg']], ], ], 'testName' => 'schedule_event_labels_and_metadata', diff --git a/tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_labels_and_metadata.json b/tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_labels_and_metadata.json index 514a39a47..e4467c306 100644 --- a/tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_labels_and_metadata.json +++ b/tests/Interop/Server/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_labels_and_metadata.json @@ -2,7 +2,7 @@ "content": [ { "type": "text", - "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Release Planning\\\" scheduled successfully for \\\"2024-12-10\\\".\",\n \"event_details\": {\n \"title\": \"Release Planning\",\n \"date\": \"2024-12-10\",\n \"type\": \"meeting\",\n \"time\": \"All day\",\n \"priority\": \"Normal\",\n \"attendees\": [],\n \"invites_will_be_sent\": false,\n \"labels\": {\n \"team\": \"platform\"\n },\n \"metadata\": {\n \"source\": \"crm\",\n \"tags\": [],\n \"custom\": {}\n }\n }\n}" + "text": "{\n \"success\": true,\n \"message\": \"Event \\\"Release Planning\\\" scheduled successfully for \\\"2024-12-10\\\".\",\n \"event_details\": {\n \"title\": \"Release Planning\",\n \"date\": \"2024-12-10\",\n \"type\": \"meeting\",\n \"time\": \"All day\",\n \"priority\": \"Normal\",\n \"attendees\": [],\n \"invites_will_be_sent\": false,\n \"labels\": {\n \"team\": \"platform\"\n },\n \"metadata\": {\n \"source\": \"crm\",\n \"tags\": [],\n \"rooms\": {\n \"0\": \"Berlin\",\n \"1\": \"Hamburg\"\n }\n }\n }\n}" } ], "structuredContent": { @@ -22,7 +22,10 @@ "metadata": { "source": "crm", "tags": [], - "custom": {} + "rooms": { + "0": "Berlin", + "1": "Hamburg" + } } } }, From 5ececf2ab345daeb06a84223e2c53a97799b3c04 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 12:13:10 +0100 Subject: [PATCH 4/6] [Server] Set empty object defaults after schema merges and keep parse errors --- src/Capability/Discovery/SchemaGenerator.php | 26 +++++++++---- src/JsonRpc/MessageFactory.php | 4 +- .../Discovery/SchemaGeneratorTest.php | 38 +++++++++++++++++++ tests/Unit/JsonRpc/MessageFactoryTest.php | 7 ++++ 4 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 20ed747ea..14231f53b 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -247,7 +247,25 @@ private function buildParameterSchema(array $paramInfo, ?array $methodLevelParam // Run after all merges so that when a Schema attribute reshapes the parameter // (e.g. to `object`), the array `items` invariant is only enforced on what is // genuinely still an array. - return $this->ensureArrayItems($mergedSchema); + return $this->ensureObjectDefault($this->ensureArrayItems($mergedSchema)); + } + + /** + * An empty PHP array encodes as `[]`, which a client could not send back to an object-only parameter. + * + * @param array $schema + * + * @return array + */ + private function ensureObjectDefault(array $schema): array + { + $types = (array) ($schema['type'] ?? []); + + if ([] === ($schema['default'] ?? null) && \in_array('object', $types, true) && !\in_array('array', $types, true)) { + $schema['default'] = new \stdClass(); + } + + return $schema; } /** @@ -308,12 +326,6 @@ private function buildInferredParameterSchema(array $paramInfo): array // Add default value only if parameter actually has a default if ($paramInfo['has_default']) { $paramSchema['default'] = $paramInfo['default_value']; - - // An empty PHP array encodes as `[]`, which a client could not send back to an object-only parameter. - $types = (array) ($paramSchema['type'] ?? []); - if ([] === $paramSchema['default'] && \in_array('object', $types, true) && !\in_array('array', $types, true)) { - $paramSchema['default'] = new \stdClass(); - } } // Handle enums diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 71acfae5b..c7995aa69 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -127,11 +127,13 @@ public function create(string $input): MessageInterface /** * Valid JSON, but a PHP object cannot hold a property whose name starts with NUL. + * + * @throws \JsonException when the input is malformed after that name */ private static function invalidMemberName(string $input): InvalidInputMessageException { $exception = new InvalidInputMessageException('A JSON-RPC message must not have a member name starting with a NUL byte.'); - $id = json_decode($input, true)['id'] ?? null; + $id = json_decode($input, true, flags: \JSON_THROW_ON_ERROR)['id'] ?? null; if (\is_string($id) || \is_int($id)) { $exception->setRequestId($id); diff --git a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php index f1a82fc6b..97bb6f2db 100644 --- a/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php +++ b/tests/Unit/Capability/Discovery/SchemaGeneratorTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Capability\Discovery; +use Mcp\Capability\Attribute\Schema; use Mcp\Capability\Discovery\DocBlockParser; use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Discovery\SchemaValidator; @@ -708,6 +709,43 @@ public function handle(array $map = [], ?array $nullableMap = [], array $mapOrLi $this->assertSame('[]', json_encode($properties['list']['default'])); } + public function testEmptyDefaultFollowsTheFinalType(): void + { + $handler = new class { + /** + * @param array $toArray + * @param array $toDefinition + */ + public function handle( + #[Schema(type: 'array')] + array $toArray = [], + #[Schema(definition: ['type' => 'array'])] + array $toDefinition = [], + #[Schema(type: 'object')] + array $toObject = [], + ): void { + } + + /** + * @param array $map + */ + #[Schema(properties: ['map' => ['type' => 'array']])] + public function method(array $map = []): void + { + } + }; + + $properties = $this->schemaGenerator->generate(new \ReflectionMethod($handler, 'handle'))['properties']; + + $this->assertSame('[]', json_encode($properties['toArray']['default'])); + $this->assertSame('[]', json_encode($properties['toDefinition']['default'])); + $this->assertSame('{}', json_encode($properties['toObject']['default'])); + + $properties = $this->schemaGenerator->generate(new \ReflectionMethod($handler, 'method'))['properties']; + + $this->assertSame('[]', json_encode($properties['map']['default'])); + } + public function testListOfGenericArraysIsAList(): void { $handler = new class { diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 50edfb211..2c4d135fa 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -73,6 +73,13 @@ public function testEmptyObjectIsRejected(): void $this->factory->create('{}'); } + public function testMalformedJsonAfterAMemberNameStartingWithNulIsAParseError(): void + { + $this->expectException(\JsonException::class); + + $this->factory->create('{"id":1,"\u0000x":1,}'); + } + public function testAnObjectWithNumericKeysIsNotABatch(): void { $this->expectException(InvalidInputMessageException::class); From 6036e6d83ea70ed66e02ec2d503334477965e4e5 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 12:43:57 +0100 Subject: [PATCH 5/6] [Capability] Keep a Traversable passed to an iterable parameter --- src/Capability/Registry/ReferenceHandler.php | 3 ++- .../Unit/Capability/Registry/ReferenceHandlerTest.php | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index e0e4d5c47..94f87f6be 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -247,7 +247,8 @@ private function castArgumentType(mixed $argument, \ReflectionParameter $paramet 'string' => (string) $argument, 'bool', 'boolean' => $this->castToBoolean($argument), 'float', 'double' => $this->castToFloat($argument), - 'array', 'iterable' => $this->castToArray($argument), + 'array' => $this->castToArray($argument), + 'iterable' => $argument instanceof \Traversable ? $argument : $this->castToArray($argument), default => $argument, }; } catch (\TypeError $e) { diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php index 298cd38b8..1d8a7d0aa 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -174,6 +174,16 @@ public function testParameterTypeDecidesHowJsonObjectsAreDelivered(): void $this->assertSame($value, $handler->handle(new ElementReference(static fn ($value) => $value), $context)); } + public function testIterableParameterKeepsATraversable(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $value = new \ArrayIterator(['a' => 1]); + $context = ['_session' => $session, '_request' => new PingRequest(), 'value' => $value]; + + $this->assertSame($value, (new ReferenceHandler())->handle(new ElementReference(static fn (iterable $value): iterable => $value), $context)); + } + public function testHandleKeepsTheDefaultOfANonNullableParameterOnAnExplicitNull(): void { $session = $this->createMock(SessionInterface::class); From e0ef0296d39e43fd5cbe4d93f1f6c45c0f68f58c Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 13:46:34 +0100 Subject: [PATCH 6/6] [Server] Refuse only tools/call on member names starting with NUL --- CHANGELOG.md | 2 +- src/JsonRpc/MessageFactory.php | 34 +++++-------- src/Schema/JsonRpc/Request.php | 8 +++ src/Schema/Request/CallToolRequest.php | 5 ++ tests/Unit/JsonRpc/MessageFactoryTest.php | 49 +++++++++++++++++++ .../Server/Transport/StdioDualEraTest.php | 22 +++++++++ 6 files changed, 97 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee9d9166..823238301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] Keep the JSON types of `tools/call` arguments, so validation sees what the client sent: `{}` and `{"0":"x"}` are objects, `[]` and `["x"]` are arrays. Nested JSON objects in `CallToolRequest::$arguments` and in the arguments of `ToolHandlerInterface::execute()` are `\stdClass`. * [BC Break] Tool parameters without a type, or typed `mixed`, receive nested JSON objects as `\stdClass`. Parameters typed `array` still receive arrays at every level. Parameters typed `object` or `\stdClass`, which always failed with a `TypeError`, receive `\stdClass`. * [BC Break] Reject `tools/call` when `arguments` is not a JSON object with `-32600`, including `"arguments": []`. -* [BC Break] Reject a JSON-RPC message with a member name starting with a NUL byte with `-32600`, as a PHP object cannot hold such a name. +* [BC Break] Reject `tools/call` with `-32600` when its message has a member name starting with a NUL byte, as its arguments cannot keep their JSON types then. * [BC Break] `array` parameters generate `{"type": "object"}` instead of `{"type": "array"}`, and `array` parameters accept a JSON array or a JSON object. ### Client diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index c7995aa69..2bf934457 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -106,17 +106,18 @@ public function create(string $input): MessageInterface throw $e; } - throw self::invalidMemberName($input); + // A PHP object cannot hold a property whose name starts with NUL. + $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); } - if (!$data instanceof \stdClass) { + if (!$data instanceof \stdClass && (!\is_array($data) || str_starts_with(ltrim($input), '['))) { throw new InvalidInputMessageException('A JSON-RPC message must be a single JSON object.'); } try { return $this->createMessage($data); } catch (InvalidInputMessageException $e) { - $id = $data->id ?? null; + $id = ((array) $data)['id'] ?? null; if (\is_string($id) || \is_int($id)) { $e->setRequestId($id); } @@ -125,29 +126,14 @@ public function create(string $input): MessageInterface } } - /** - * Valid JSON, but a PHP object cannot hold a property whose name starts with NUL. - * - * @throws \JsonException when the input is malformed after that name - */ - private static function invalidMemberName(string $input): InvalidInputMessageException - { - $exception = new InvalidInputMessageException('A JSON-RPC message must not have a member name starting with a NUL byte.'); - $id = json_decode($input, true, flags: \JSON_THROW_ON_ERROR)['id'] ?? null; - - if (\is_string($id) || \is_int($id)) { - $exception->setRequestId($id); - } - - return $exception; - } - /** * Creates a single message object from parsed JSON data. * + * @param \stdClass|array $message + * * @throws InvalidInputMessageException */ - private function createMessage(\stdClass $message): MessageInterface + private function createMessage(\stdClass|array $message): MessageInterface { /** @var array $data */ $data = Json::objectsToArrays($message); @@ -171,8 +157,12 @@ private function createMessage(\stdClass $message): MessageInterface $messageClass = $this->findMessageClassByMethod($data['method']); + if (\is_array($message) && is_subclass_of($messageClass, Request::class) && $messageClass::requiresJsonTypes()) { + throw new InvalidInputMessageException(\sprintf('"%s" cannot be served from a message with a member name starting with a NUL byte.', $data['method'])); + } + // Each message knows which of its params are free-form JSON, so it gets them as decoded. - if (isset($message->params)) { + if ($message instanceof \stdClass && isset($message->params)) { $data['params'] = $message->params; } diff --git a/src/Schema/JsonRpc/Request.php b/src/Schema/JsonRpc/Request.php index 53976ba97..af60a3303 100644 --- a/src/Schema/JsonRpc/Request.php +++ b/src/Schema/JsonRpc/Request.php @@ -72,6 +72,14 @@ public static function fromArray(array $data): static return $request; } + /** + * Whether the request cannot be served from params that lost the difference between `{}` and `[]`. + */ + public static function requiresJsonTypes(): bool + { + return false; + } + /** * @return array */ diff --git a/src/Schema/Request/CallToolRequest.php b/src/Schema/Request/CallToolRequest.php index 86270aa04..3d8e970b3 100644 --- a/src/Schema/Request/CallToolRequest.php +++ b/src/Schema/Request/CallToolRequest.php @@ -37,6 +37,11 @@ public static function getMethod(): string return 'tools/call'; } + public static function requiresJsonTypes(): bool + { + return true; + } + protected static function paramsToArray(\stdClass $params): array { $array = parent::paramsToArray($params); diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 2c4d135fa..f027b78f9 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -80,6 +80,55 @@ public function testMalformedJsonAfterAMemberNameStartingWithNulIsAParseError(): $this->factory->create('{"id":1,"\u0000x":1,}'); } + /** + * @param class-string $expected + */ + #[DataProvider('provideMessagesWithAMemberNameStartingWithNul')] + public function testMemberNameStartingWithNulKeepsTheMessage(string $json, string $expected): void + { + $this->assertInstanceOf($expected, $this->factory->create($json)); + } + + /** + * @return iterable + */ + public static function provideMessagesWithAMemberNameStartingWithNul(): iterable + { + yield 'request' => ['{"jsonrpc":"2.0","id":1,"method":"ping","params":{"_meta":{"\u0000x":1}}}', PingRequest::class]; + yield 'notification' => ['{"jsonrpc":"2.0","method":"notifications/initialized","params":{"_meta":{"\u0000x":1}}}', InitializedNotification::class]; + yield 'response' => ['{"jsonrpc":"2.0","id":1,"result":{"content":{"\u0000x":1}}}', Response::class]; + yield 'error' => ['{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"m","data":{"\u0000x":1}}}', Error::class]; + } + + public function testMemberNameStartingWithNulRefusesAToolCall(): void + { + try { + $this->factory->create('{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"t","arguments":{"a":{"\u0000x":1}}}}'); + } catch (InvalidInputMessageException $e) { + $this->assertSame(7, $e->getRequestId()); + + return; + } + + $this->fail('The tool call should have been refused.'); + } + + public function testBatchWithAMemberNameStartingWithNulIsRejected(): void + { + $this->expectException(InvalidInputMessageException::class); + $this->expectExceptionMessage('must be a single JSON object'); + + $this->factory->create(' [{"jsonrpc":"2.0","id":1,"method":"ping","params":{"_meta":{"\u0000x":1}}}]'); + } + + public function testAnObjectWithNumericKeysAndAMemberNameStartingWithNulIsNotABatch(): void + { + $this->expectException(InvalidInputMessageException::class); + $this->expectExceptionMessage('missing "method"'); + + $this->factory->create('{"0":{"\u0000x":1}}'); + } + public function testAnObjectWithNumericKeysIsNotABatch(): void { $this->expectException(InvalidInputMessageException::class); diff --git a/tests/Unit/Server/Transport/StdioDualEraTest.php b/tests/Unit/Server/Transport/StdioDualEraTest.php index 6595bd686..700f425b4 100644 --- a/tests/Unit/Server/Transport/StdioDualEraTest.php +++ b/tests/Unit/Server/Transport/StdioDualEraTest.php @@ -199,6 +199,28 @@ public static function nonObjectArguments(): iterable yield 'null' => ['null']; } + public function testMemberNameStartingWithANulByteKeepsOtherRequests(): void + { + $params = ['_probe' => ["\0x" => 1]]; + $openings = [ + 'handshake' => [ + self::initialize(1), + ['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], + ['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => $params], + ], + 'modern' => [self::modern(2, 'tools/list', $params)], + ]; + + foreach ($openings as $messages) { + $lines = $this->exchange(self::builder(), $messages); + + $answer = $lines[\count($lines) - 1]; + + $this->assertSame(2, $answer['id']); + $this->assertArrayHasKey('tools', $answer['result']); + } + } + public function testMemberNameStartingWithANulByteIsRefused(): void { $params = ['name' => 'echo', 'arguments' => ["\0text" => 'hi']];