[Server] Handle malformed JSON-RPC input graceful (#412)

* Never let a throwable escape Protocol::processInput()

* Validate payload types in the schema hydration methods

* Stop leaking handler exception messages to the client
This commit is contained in:
Christopher Hertel 2026-08-14 22:34:01 +02:00 • committed by GitHub
commit c8ed07bba2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 858 additions and 58 deletions

View file

@ -177,6 +177,10 @@ final class MessageFactory
throw new InvalidInputMessageException('Invalid JSON-RPC message: missing "method", "result", or "error" field.');
}
if (!\is_string($data['method'])) {
throw new InvalidInputMessageException('Invalid JSON-RPC message: "method" must be a string.');
}
$messageClass = $this->findMessageClassByMethod($data['method']);
return $messageClass::fromArray($data);

View file

@ -60,7 +60,20 @@ class Annotations implements \JsonSerializable
{
$audience = null;
if (isset($data['audience']) && \is_array($data['audience'])) {
$audience = array_map(static fn (string $r) => Role::from($r), $data['audience']);
$audience = array_map(
static function (mixed $role): Role {
if (!\is_string($role) || null === $case = Role::tryFrom($role)) {
throw new InvalidArgumentException('Each entry in "audience" must be a valid role.');
}
return $case;
},
$data['audience'],
);
}
if (isset($data['priority']) && !\is_float($data['priority']) && !\is_int($data['priority'])) {
throw new InvalidArgumentException('Invalid "priority" in Annotations data; expected a number.');
}
return new self(
@ -69,6 +82,24 @@ class Annotations implements \JsonSerializable
);
}
/**
* Hydrates an optional "annotations" field, rejecting a value that is present but not an object.
*
* @param string $context the surrounding schema type, used for the error message
*/
public static function tryFromArray(mixed $data, string $context): ?self
{
if (null === $data) {
return null;
}
if (!\is_array($data)) {
throw new InvalidArgumentException(\sprintf('Invalid "annotations" in %s data; expected an array.', $context));
}
return self::fromArray($data);
}
/**
* @return AnnotationsData
*/

View file

@ -44,14 +44,17 @@ class AudioContent extends Content
*/
public static function fromArray(array $data): self
{
if (!isset($data['data']) || !isset($data['mimeType'])) {
throw new InvalidArgumentException('Invalid or missing "data" or "mimeType" in AudioContent data.');
if (!isset($data['data']) || !\is_string($data['data'])) {
throw new InvalidArgumentException('Missing or invalid "data" in AudioContent data.');
}
if (!isset($data['mimeType']) || !\is_string($data['mimeType'])) {
throw new InvalidArgumentException('Missing or invalid "mimeType" in AudioContent data.');
}
return new self(
$data['data'],
$data['mimeType'],
isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null
Annotations::tryFromArray($data['annotations'] ?? null, 'AudioContent')
);
}

View file

@ -54,6 +54,13 @@ class BlobResourceContents extends ResourceContents
throw new InvalidArgumentException('Missing or invalid "blob" for BlobResourceContents.');
}
if (isset($data['mimeType']) && !\is_string($data['mimeType'])) {
throw new InvalidArgumentException('Invalid "mimeType" for BlobResourceContents.');
}
if (isset($data['_meta']) && !\is_array($data['_meta'])) {
throw new InvalidArgumentException('Invalid "_meta" for BlobResourceContents.');
}
return new self($data['uri'], $data['mimeType'] ?? null, $data['blob'], $data['_meta'] ?? null);
}

View file

@ -62,7 +62,7 @@ class EmbeddedResource extends Content
return new self(
$resourceInstance,
isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null,
Annotations::tryFromArray($data['annotations'] ?? null, 'EmbeddedResource'),
);
}

View file

@ -58,6 +58,9 @@ class PromptMessage extends Content
$contentData = $data['content'];
$contentType = $contentData['type'] ?? null;
if (!\is_string($contentType)) {
throw new InvalidArgumentException('Missing or invalid content "type" for PromptMessage.');
}
$content = match ($contentType) {
'text' => TextContent::fromArray($contentData),
@ -67,7 +70,11 @@ class PromptMessage extends Content
default => throw new InvalidArgumentException(\sprintf('Invalid content type "%s" for PromptMessage.', $contentType)),
};
return new self(Role::from($data['role']), $content);
if (null === $role = Role::tryFrom($data['role'])) {
throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in PromptMessage data.', $data['role']));
}
return new self($role, $content);
}
/**

View file

@ -45,9 +45,15 @@ class SamplingMessage extends Content
throw new InvalidArgumentException('Missing or invalid "content" in SamplingMessage data.');
}
$role = Role::from($data['role']);
if (null === $role = Role::tryFrom($data['role'])) {
throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in SamplingMessage data.', $data['role']));
}
$contentData = $data['content'];
$contentType = $contentData['type'] ?? null;
if (!\is_string($contentType)) {
throw new InvalidArgumentException('Missing or invalid content "type" for SamplingMessage.');
}
$contentInstance = match ($contentType) {
'text' => TextContent::fromArray($contentData),

View file

@ -56,7 +56,7 @@ class TextContent extends Content
return new self(
$data['text'],
isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null
Annotations::tryFromArray($data['annotations'] ?? null, 'TextContent')
);
}

View file

@ -54,6 +54,13 @@ class TextResourceContents extends ResourceContents
throw new InvalidArgumentException('Missing or invalid "text" for TextResourceContents.');
}
if (isset($data['mimeType']) && !\is_string($data['mimeType'])) {
throw new InvalidArgumentException('Invalid "mimeType" for TextResourceContents.');
}
if (isset($data['_meta']) && !\is_array($data['_meta'])) {
throw new InvalidArgumentException('Invalid "_meta" for TextResourceContents.');
}
return new self($data['uri'], $data['mimeType'] ?? null, $data['text'], $data['_meta'] ?? null);
}

View file

@ -35,6 +35,9 @@ final class ElicitationSchema implements \JsonSerializable
}
foreach ($required as $name) {
if (!\is_string($name)) {
throw new InvalidArgumentException('Each entry in "required" must be a string.');
}
if (!\array_key_exists($name, $properties)) {
throw new InvalidArgumentException(\sprintf('Required property "%s" is not defined in properties.', $name));
}
@ -68,6 +71,10 @@ final class ElicitationSchema implements \JsonSerializable
$properties[$name] = self::createSchemaDefinition($propertyData);
}
if (isset($data['required']) && !\is_array($data['required'])) {
throw new InvalidArgumentException('Invalid "required" for elicitation schema; expected an array.');
}
return new self(
properties: $properties,
required: $data['required'] ?? [],

View file

@ -11,6 +11,8 @@
namespace Mcp\Schema\Extension\Apps;
use Mcp\Exception\InvalidArgumentException;
/**
* Metadata for the _meta.ui field on resource content in a resources/read response.
*
@ -43,6 +45,19 @@ final class UiResourceContentMeta implements \JsonSerializable
*/
public static function fromArray(array $data): self
{
if (isset($data['csp']) && !\is_array($data['csp'])) {
throw new InvalidArgumentException('Invalid "csp" in UiResourceContentMeta data; expected an array.');
}
if (isset($data['permissions']) && !\is_array($data['permissions'])) {
throw new InvalidArgumentException('Invalid "permissions" in UiResourceContentMeta data; expected an array.');
}
if (isset($data['domain']) && !\is_string($data['domain'])) {
throw new InvalidArgumentException('Invalid "domain" in UiResourceContentMeta data.');
}
if (isset($data['prefersBorder']) && !\is_bool($data['prefersBorder'])) {
throw new InvalidArgumentException('Invalid "prefersBorder" in UiResourceContentMeta data.');
}
return new self(
csp: isset($data['csp']) ? UiResourceCsp::fromArray($data['csp']) : null,
permissions: isset($data['permissions']) ? UiResourcePermissions::fromArray($data['permissions']) : null,

View file

@ -11,6 +11,8 @@
namespace Mcp\Schema\Extension\Apps;
use Mcp\Exception\InvalidArgumentException;
/**
* Content Security Policy configuration for MCP App resources.
*
@ -47,6 +49,12 @@ final class UiResourceCsp implements \JsonSerializable
*/
public static function fromArray(array $data): self
{
foreach (['connectDomains', 'resourceDomains', 'frameDomains', 'baseUriDomains'] as $key) {
if (isset($data[$key]) && !\is_array($data[$key])) {
throw new InvalidArgumentException(\sprintf('Invalid "%s" in UiResourceCsp data; expected an array.', $key));
}
}
return new self(
connectDomains: $data['connectDomains'] ?? null,
resourceDomains: $data['resourceDomains'] ?? null,

View file

@ -11,6 +11,8 @@
namespace Mcp\Schema\Extension\Apps;
use Mcp\Exception\InvalidArgumentException;
/**
* Metadata for the _meta.ui field on a Tool, linking it to a UI resource.
*
@ -39,9 +41,25 @@ final class UiToolMeta implements \JsonSerializable
*/
public static function fromArray(array $data): self
{
if (isset($data['resourceUri']) && !\is_string($data['resourceUri'])) {
throw new InvalidArgumentException('Invalid "resourceUri" in UiToolMeta data.');
}
if (isset($data['visibility']) && !\is_array($data['visibility'])) {
throw new InvalidArgumentException('Invalid "visibility" in UiToolMeta data; expected an array.');
}
return new self(
resourceUri: $data['resourceUri'] ?? null,
visibility: isset($data['visibility']) ? array_map(ToolVisibility::from(...), $data['visibility']) : null,
visibility: isset($data['visibility']) ? array_map(
static function (mixed $entry): ToolVisibility {
if (!\is_string($entry) || null === $case = ToolVisibility::tryFrom($entry)) {
throw new InvalidArgumentException('Each entry in "visibility" of UiToolMeta data must be a valid tool visibility.');
}
return $case;
},
$data['visibility'],
) : null,
);
}

View file

@ -65,8 +65,36 @@ class Icon implements \JsonSerializable
if (empty($data['src']) || !\is_string($data['src'])) {
throw new InvalidArgumentException('Invalid or missing "src" in Icon data.');
}
if (isset($data['mimeType']) && !\is_string($data['mimeType'])) {
throw new InvalidArgumentException('Invalid "mimeType" in Icon data.');
}
if (isset($data['sizes']) && !\is_array($data['sizes'])) {
throw new InvalidArgumentException('Invalid "sizes" in Icon data.');
}
return new self($data['src'], $data['mimeTypes'] ?? null, $data['sizes'] ?? null);
return new self($data['src'], $data['mimeType'] ?? null, $data['sizes'] ?? null);
}
/**
* Hydrates an "icons" list, rejecting entries that are not objects.
*
* @param array<mixed> $icons
* @param string $context the surrounding schema type, used for the error message
*
* @return self[]
*/
public static function listFromArray(array $icons, string $context): array
{
return array_map(
static function (mixed $icon) use ($context): self {
if (!\is_array($icon)) {
throw new InvalidArgumentException(\sprintf('Each entry in "icons" of %s data must be an array.', $context));
}
return self::fromArray($icon);
},
$icons,
);
}
/**

View file

@ -57,7 +57,14 @@ class Implementation implements \JsonSerializable
throw new InvalidArgumentException('Invalid "icons" in Implementation data; expected an array.');
}
$data['icons'] = array_map(Icon::fromArray(...), $data['icons']);
$data['icons'] = Icon::listFromArray($data['icons'], 'Implementation');
}
if (isset($data['description']) && !\is_string($data['description'])) {
throw new InvalidArgumentException('Invalid "description" in Implementation data.');
}
if (isset($data['websiteUrl']) && !\is_string($data['websiteUrl'])) {
throw new InvalidArgumentException('Invalid "websiteUrl" in Implementation data.');
}
return new self(

View file

@ -11,6 +11,8 @@
namespace Mcp\Schema;
use Mcp\Exception\InvalidArgumentException;
/**
* The server's preferences for model selection, requested of the client during sampling.
*
@ -61,14 +63,35 @@ class ModelPreferences implements \JsonSerializable
*/
public static function fromArray(array $preferences): self
{
if (isset($preferences['hints']) && !\is_array($preferences['hints'])) {
throw new InvalidArgumentException('Invalid "hints" in ModelPreferences data.');
}
return new self(
$preferences['hints'] ?? null,
$preferences['costPriority'] ?? null,
$preferences['speedPriority'] ?? null,
$preferences['intelligencePriority'] ?? null,
self::priority($preferences, 'costPriority'),
self::priority($preferences, 'speedPriority'),
self::priority($preferences, 'intelligencePriority'),
);
}
/**
* @param array<string, mixed> $preferences
*/
private static function priority(array $preferences, string $key): ?float
{
if (!isset($preferences[$key])) {
return null;
}
// JSON numbers decode to int when they have no fractional part.
if (!\is_float($preferences[$key]) && !\is_int($preferences[$key])) {
throw new InvalidArgumentException(\sprintf('Invalid "%s" in ModelPreferences data; expected a number.', $key));
}
return (float) $preferences[$key];
}
/**
* @return ModelPreferencesData
*/

View file

@ -48,6 +48,10 @@ class CancelledNotification extends Notification
throw new InvalidArgumentException('Invalid or missing "requestId" parameter for "notifications/cancelled" notification.');
}
if (isset($params['reason']) && !\is_string($params['reason'])) {
throw new InvalidArgumentException('Invalid "reason" parameter for "notifications/cancelled" notification.');
}
return new self($params['requestId'], $params['reason'] ?? null);
}

View file

@ -47,7 +47,14 @@ class LoggingMessageNotification extends Notification
throw new InvalidArgumentException('Missing "data" parameter for "notifications/message" notification.');
}
$level = LoggingLevel::from($params['level']);
if (null === $level = LoggingLevel::tryFrom($params['level'])) {
throw new InvalidArgumentException(\sprintf('Invalid "level" parameter "%s" for "notifications/message" notification.', $params['level']));
}
if (isset($params['logger']) && !\is_string($params['logger'])) {
throw new InvalidArgumentException('Invalid "logger" parameter for "notifications/message" notification.');
}
$data = \is_string($params['data']) ? $params['data'] : json_encode($params['data']);
return new self($level, $data, $params['logger'] ?? null);

View file

@ -44,18 +44,27 @@ class ProgressNotification extends Notification
protected static function fromParams(?array $params): Notification
{
if (!isset($params['progressToken']) || !\is_string($params['progressToken'])) {
// JSON numbers decode to int when they have no fractional part.
if (!isset($params['progressToken']) || !\is_string($params['progressToken']) && !\is_int($params['progressToken'])) {
throw new InvalidArgumentException('Missing or invalid "progressToken" parameter for "notifications/progress" notification.');
}
if (!isset($params['progress']) || !\is_float($params['progress'])) {
if (!isset($params['progress']) || !\is_float($params['progress']) && !\is_int($params['progress'])) {
throw new InvalidArgumentException('Missing or invalid "progress" parameter for "notifications/progress" notification.');
}
if (isset($params['total']) && !\is_float($params['total']) && !\is_int($params['total'])) {
throw new InvalidArgumentException('Invalid "total" parameter for "notifications/progress" notification.');
}
if (isset($params['message']) && !\is_string($params['message'])) {
throw new InvalidArgumentException('Invalid "message" parameter for "notifications/progress" notification.');
}
return new self(
$params['progressToken'],
$params['progress'],
$params['total'] ?? null,
(float) $params['progress'],
isset($params['total']) ? (float) $params['total'] : null,
$params['message'] ?? null,
);
}

View file

@ -67,19 +67,34 @@ class Prompt implements \JsonSerializable
}
$arguments = null;
if (isset($data['arguments']) && \is_array($data['arguments'])) {
$arguments = array_map(static fn (array $argData) => PromptArgument::fromArray($argData), $data['arguments']);
$arguments = array_map(
static function (mixed $argData): PromptArgument {
if (!\is_array($argData)) {
throw new InvalidArgumentException('Each entry in "arguments" of Prompt data must be an array.');
}
return PromptArgument::fromArray($argData);
},
$data['arguments'],
);
}
if (!empty($data['_meta']) && !\is_array($data['_meta'])) {
if (isset($data['_meta']) && !\is_array($data['_meta'])) {
throw new InvalidArgumentException('Invalid "_meta" in Prompt data.');
}
if (isset($data['title']) && !\is_string($data['title'])) {
throw new InvalidArgumentException('Invalid "title" in Prompt data.');
}
if (isset($data['description']) && !\is_string($data['description'])) {
throw new InvalidArgumentException('Invalid "description" in Prompt data.');
}
return new self(
name: $data['name'],
title: $data['title'] ?? null,
description: $data['description'] ?? null,
arguments: $arguments,
icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null,
icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'Prompt') : null,
meta: isset($data['_meta']) ? $data['_meta'] : null
);
}

Some files were not shown because too many files have changed in this diff Show more