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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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<string, T>` parameters generate `{"type": "object"}` instead of `{"type": "array"}`, and `array<int, T>` parameters accept a JSON array or a JSON object.

### Client

Expand Down
2 changes: 1 addition & 1 deletion docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
31 changes: 31 additions & 0 deletions docs/servers/schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, int> $stock a JSON object
* @param string[] $labels a JSON array
* @param array<int, string> $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
Expand Down
26 changes: 19 additions & 7 deletions examples/server/complex-tool-schema/McpEventScheduler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> $labels labels of the event by name
* @param \stdClass|null $metadata free-form data stored with the event, exactly as sent
*
* @return array<string, mixed> confirmation of the scheduled event
*/
Expand All @@ -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'));

Expand All @@ -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]);

Expand Down
24 changes: 21 additions & 3 deletions src/Capability/Discovery/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $schema
*
* @return array<string, mixed>
*/
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;
}

/**
Expand Down Expand Up @@ -805,8 +823,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*</', $normalizedType)) {
// The key type of a generic array decides between a JSON array and a JSON object.
if (preg_match('/^(?:non-empty-)?array\s*<.*>$/s', $normalizedType)) {
return (array) $this->mapTypeToSchema($normalizedType)['type'];
}

Expand Down
20 changes: 19 additions & 1 deletion src/Capability/Registry/ReferenceHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -243,13 +248,24 @@ private function castArgumentType(mixed $argument, \ReflectionParameter $paramet
'bool', 'boolean' => $this->castToBoolean($argument),
'float', 'double' => $this->castToFloat($argument),
'array' => $this->castToArray($argument),
'iterable' => $argument instanceof \Traversable ? $argument : $this->castToArray($argument),
default => $argument,
};
} catch (\TypeError $e) {
throw new InvalidArgumentException("Value cannot be cast to required type `{$typeName}`.", 0, $e);
}
}

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.
*/
Expand Down Expand Up @@ -307,10 +323,12 @@ private function castToFloat(mixed $argument): float
/**
* Helper to cast strictly to array.
*
* @return array<int, mixed>
* @return array<int|string, mixed>
*/
private function castToArray(mixed $argument): array
{
$argument = Json::objectsToArrays($argument);

if (\is_array($argument)) {
return $argument;
}
Expand Down
37 changes: 37 additions & 0 deletions src/JsonRpc/Json.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\JsonRpc;

/**
* @internal
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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;
}
}
35 changes: 28 additions & 7 deletions src/JsonRpc/MessageFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,27 @@ 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;
}

// 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)) {
// 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 && (!\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) {
if (\is_string($data['id'] ?? null) || \is_int($data['id'] ?? null)) {
$e->setRequestId($data['id']);
$id = ((array) $data)['id'] ?? null;
if (\is_string($id) || \is_int($id)) {
$e->setRequestId($id);
}

throw $e;
Expand All @@ -120,12 +129,15 @@ public function create(string $input): MessageInterface
/**
* Creates a single message object from parsed JSON data.
*
* @param array<string, mixed> $data
* @param \stdClass|array<string, mixed> $message
*
* @throws InvalidInputMessageException
*/
private function createMessage(array $data): MessageInterface
private function createMessage(\stdClass|array $message): MessageInterface
{
/** @var array<string, mixed> $data */
$data = Json::objectsToArrays($message);

try {
if (isset($data['error'])) {
return Error::fromArray($data);
Expand All @@ -145,6 +157,15 @@ private function createMessage(array $data): 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 ($message instanceof \stdClass && isset($message->params)) {
$data['params'] = $message->params;
}

return $messageClass::fromArray($data);
} catch (InvalidArgumentException $e) {
throw new InvalidInputMessageException($e->getMessage(), 0, $e);
Expand Down
9 changes: 5 additions & 4 deletions src/Schema/JsonRpc/Notification.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>|null
* params?: array<string, mixed>|\stdClass|null
* }
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
Expand All @@ -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;
Expand Down
Loading
Loading