[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:
parent
9236824b6d
commit
c8ed07bba2
50 changed files with 858 additions and 58 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'] ?? [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue