From 31f56dd5ce763f6542b3a146480819c4e41eaa23 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 11 Aug 2026 00:21:42 +0200 Subject: [PATCH 1/3] Never let a throwable escape Protocol::processInput() --- src/Server/Protocol.php | 93 ++++++++++++-- tests/Unit/Fixtures/ThrowingRequest.php | 38 ++++++ tests/Unit/Server/ProtocolTest.php | 157 ++++++++++++++++++++++++ 3 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/Fixtures/ThrowingRequest.php diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index d9af4e4c..491b11f0 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -61,6 +61,12 @@ class Protocol public const SESSION_LOGGING_LEVEL = '_mcp.logging_level'; + /** + * Deliberately generic: unexpected throwables carry internal details such as file paths, class + * names and argument types, which must not be handed to the peer. The full exception is logged. + */ + private const INTERNAL_ERROR_MESSAGE = 'Internal server error.'; + /** * @param array>> $requestHandlers * @param array $notificationHandlers @@ -107,6 +113,66 @@ public function connect(TransportInterface $transport): void * @param TransportInterface $transport */ public function processInput(TransportInterface $transport, string $input, ?Uuid $sessionId): void + { + // Last line of defense: a malformed message must never escape as a PHP error and take the + // server process down. + try { + $this->doProcessInput($transport, $input, $sessionId); + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Uncaught exception while processing input: %s', $e->getMessage()), ['exception' => $e]); + + // Only a request may be answered. Replying to a notification would violate JSON-RPC, + // and the failure has already been logged. + if (null === $id = self::findResponseId($input)) { + return; + } + + try { + $this->sendResponse($transport, Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $id), null); + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Failed to send internal error response: %s', $e->getMessage()), ['exception' => $e]); + } + } + } + + /** + * Determines the id an unprocessable input has to be answered under. + * + * Returns null when the input carries no request at all, in which case it consists of + * notifications only and JSON-RPC forbids answering it. A batch resolves to the empty id + * because its failure cannot be attributed to one of its requests. + */ + private static function findResponseId(string $input): string|int|null + { + try { + $data = json_decode($input, true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + + if (!\is_array($data)) { + return null; + } + + if (!array_is_list($data)) { + $id = $data['id'] ?? null; + + return \is_string($id) || \is_int($id) ? $id : null; + } + + foreach ($data as $message) { + if (\is_array($message) && isset($message['id'])) { + return ''; + } + } + + return null; + } + + /** + * @param TransportInterface $transport + */ + private function doProcessInput(TransportInterface $transport, string $input, ?Uuid $sessionId): void { $this->logger->info('Received message to process.', ['message' => $input]); @@ -128,14 +194,25 @@ public function processInput(TransportInterface $transport, string $input, ?Uuid } foreach ($messages as $message) { - if ($message instanceof InvalidInputMessageException) { - $this->handleInvalidMessage($transport, $message, $session); - } elseif ($message instanceof Request) { - $this->handleRequest($transport, $message, $session); - } elseif ($message instanceof Response || $message instanceof Error) { - $this->handleResponse($message, $session); - } elseif ($message instanceof Notification) { - $this->handleNotification($message, $session); + // Guarded per message so one faulty message cannot suppress the rest of a batch. + try { + if ($message instanceof InvalidInputMessageException) { + $this->handleInvalidMessage($transport, $message, $session); + } elseif ($message instanceof Request) { + $this->handleRequest($transport, $message, $session); + } elseif ($message instanceof Response || $message instanceof Error) { + $this->handleResponse($message, $session); + } elseif ($message instanceof Notification) { + $this->handleNotification($message, $session); + } + } catch (\Throwable $e) { + $this->logger->error(\sprintf('Uncaught exception while handling message: %s', $e->getMessage()), ['exception' => $e]); + + // Only a request may be answered; a notification or a response must not produce one. + if ($message instanceof Request) { + $error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $message->getId()); + $this->sendResponse($transport, $error, $session); + } } } diff --git a/tests/Unit/Fixtures/ThrowingRequest.php b/tests/Unit/Fixtures/ThrowingRequest.php new file mode 100644 index 00000000..d5b4136d --- /dev/null +++ b/tests/Unit/Fixtures/ThrowingRequest.php @@ -0,0 +1,38 @@ + + */ +final class ThrowingRequest extends Request +{ + public static function getMethod(): string + { + return 'test/throwing'; + } + + protected static function fromParams(?array $params): static + { + throw new \TypeError('Internal detail that must not leak to the client.'); + } + + protected function getParams(): ?array + { + return null; + } +} diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index f1d1c834..b75c36db 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -21,12 +21,14 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\LoggingMessageNotification; use Mcp\Schema\Request\CallToolRequest; +use Mcp\Schema\Request\PingRequest; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Protocol; use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Transport\TransportInterface; +use Mcp\Tests\Unit\Fixtures\ThrowingRequest; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -349,6 +351,161 @@ public function testInvalidMessageStructureReturnsError(): void $this->assertEquals(Error::INVALID_REQUEST, $message['error']['code']); } + #[TestDox('An unexpected throwable while creating a message returns an internal error under its id')] + public function testUnexpectedThrowableWhileCreatingMessagesReturnsInternalError(): void + { + $sent = null; + $this->transport->expects($this->once()) + ->method('send') + ->willReturnCallback(static function ($data) use (&$sent) { + $sent = $data; + }); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: new MessageFactory([ThrowingRequest::class]), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "id": 1, "method": "test/throwing"}', + Uuid::v4() + ); + + $decoded = json_decode((string) $sent, true); + $this->assertSame(Error::INTERNAL_ERROR, $decoded['error']['code']); + $this->assertSame(1, $decoded['id'], 'The peer must be able to correlate the failure with its request.'); + $this->assertStringNotContainsString('must not leak', $decoded['error']['message']); + } + + #[TestDox('A batch that fails to hydrate is answered once, under the empty id')] + public function testBatchThatFailsToHydrateIsAnsweredOnce(): void + { + $sent = []; + $this->transport->method('send')->willReturnCallback(static function ($data) use (&$sent) { + $sent[] = $data; + }); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: new MessageFactory([PingRequest::class, ThrowingRequest::class]), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '[{"jsonrpc": "2.0", "id": 1, "method": "ping"}, {"jsonrpc": "2.0", "id": 2, "method": "test/throwing"}]', + Uuid::v4() + ); + + $this->assertCount(1, $sent); + + $decoded = json_decode($sent[0], true); + $this->assertSame(Error::INTERNAL_ERROR, $decoded['error']['code']); + $this->assertSame('', $decoded['id'], 'The failure cannot be attributed to one request of the batch.'); + } + + #[TestDox('A batch holding only notifications is not answered when processing fails')] + public function testBatchOfNotificationsIsNotAnsweredWhenProcessingFails(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('save')->willThrowException(new \RuntimeException('storage is gone')); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + $this->transport->expects($this->never())->method('send'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '[{"jsonrpc": "2.0", "method": "notifications/initialized"}]', + Uuid::v4() + ); + } + + #[TestDox('An unexpected throwable while saving the session does not answer a notification')] + public function testUnexpectedThrowableWhileSavingSessionDoesNotEscape(): void + { + $session = $this->createMock(SessionInterface::class); + $session->method('save')->willThrowException(new \RuntimeException('storage is gone')); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + // JSON-RPC forbids answering a notification, so the failure is only logged. + $this->transport->expects($this->never())->method('send'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + Uuid::v4() + ); + } + + #[TestDox('A failing notification event listener does not produce a response')] + public function testFailingNotificationListenerDoesNotProduceResponse(): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + $queue = []; + $session->method('get')->willReturnCallback(static function ($key, $default = null) use (&$queue) { + return '_mcp.outgoing_queue' === $key ? $queue : $default; + }); + $session->method('set')->willReturnCallback(static function ($key, $value) use (&$queue) { + if ('_mcp.outgoing_queue' === $key) { + $queue = $value; + } + }); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->method('dispatch')->willReturnCallback(static function ($event) { + if ($event instanceof NotificationEvent) { + throw new \RuntimeException('listener blew up'); + } + + return $event; + }); + + $this->transport->expects($this->never())->method('send'); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + eventDispatcher: $dispatcher, + ); + + $sessionId = Uuid::v4(); + $protocol->processInput( + $this->transport, + '{"jsonrpc": "2.0", "method": "notifications/initialized"}', + $sessionId + ); + + $this->assertSame([], $protocol->consumeOutgoingMessages($sessionId)); + } + #[TestDox('Request without handler returns method not found error')] public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void { From 8ad14b31bdfe2b83d28923ff3d9ef35695f7b3d0 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 11 Aug 2026 00:21:42 +0200 Subject: [PATCH 2/3] Validate payload types in the schema hydration methods --- src/JsonRpc/MessageFactory.php | 4 + src/Schema/Annotations.php | 33 +++++++- src/Schema/Content/AudioContent.php | 9 +- src/Schema/Content/BlobResourceContents.php | 7 ++ src/Schema/Content/EmbeddedResource.php | 2 +- src/Schema/Content/PromptMessage.php | 9 +- src/Schema/Content/SamplingMessage.php | 8 +- src/Schema/Content/TextContent.php | 2 +- src/Schema/Content/TextResourceContents.php | 7 ++ src/Schema/Elicitation/ElicitationSchema.php | 7 ++ .../Extension/Apps/UiResourceContentMeta.php | 15 ++++ src/Schema/Extension/Apps/UiResourceCsp.php | 8 ++ src/Schema/Extension/Apps/UiToolMeta.php | 20 ++++- src/Schema/Icon.php | 30 ++++++- src/Schema/Implementation.php | 9 +- src/Schema/ModelPreferences.php | 29 ++++++- .../Notification/CancelledNotification.php | 4 + .../LoggingMessageNotification.php | 9 +- .../Notification/ProgressNotification.php | 17 +++- src/Schema/Prompt.php | 21 ++++- src/Schema/PromptArgument.php | 7 ++ .../Request/CompletionCompleteRequest.php | 16 +++- .../Request/CreateSamplingMessageRequest.php | 29 ++++++- src/Schema/Request/InitializeRequest.php | 12 +-- src/Schema/Request/ListPromptsRequest.php | 5 ++ .../Request/ListResourceTemplatesRequest.php | 5 ++ src/Schema/Request/ListResourcesRequest.php | 5 ++ src/Schema/Request/ListToolsRequest.php | 5 ++ src/Schema/Request/SetLogLevelRequest.php | 6 +- src/Schema/ResourceDefinition.php | 17 +++- src/Schema/ResourceTemplate.php | 10 ++- src/Schema/Result/CallToolResult.php | 19 ++++- .../Result/CompletionCompleteResult.php | 12 +++ .../Result/CreateSamplingMessageResult.php | 5 +- src/Schema/Result/ElicitResult.php | 5 +- src/Schema/Result/GetPromptResult.php | 8 ++ src/Schema/Result/InitializeResult.php | 7 ++ src/Schema/Result/ListPromptsResult.php | 15 +++- .../Result/ListResourceTemplatesResult.php | 15 +++- src/Schema/Result/ListResourcesResult.php | 15 +++- src/Schema/Result/ListToolsResult.php | 15 +++- src/Schema/Root.php | 4 + src/Schema/Tool.php | 2 +- src/Schema/ToolAnnotations.php | 12 +++ tests/Unit/JsonRpc/MalformedInputTest.php | 83 +++++++++++++++++++ tests/Unit/JsonRpc/MessageFactoryTest.php | 36 ++++++++ tests/Unit/Schema/Result/ElicitResultTest.php | 3 +- 47 files changed, 575 insertions(+), 48 deletions(-) create mode 100644 tests/Unit/JsonRpc/MalformedInputTest.php diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 97bac3e5..d9a895ec 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -177,6 +177,10 @@ private function createMessage(array $data): MessageInterface 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); diff --git a/src/Schema/Annotations.php b/src/Schema/Annotations.php index a448ba53..ec64e7ab 100644 --- a/src/Schema/Annotations.php +++ b/src/Schema/Annotations.php @@ -60,7 +60,20 @@ public static function fromArray(array $data): self { $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 @@ public static function fromArray(array $data): self ); } + /** + * 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 */ diff --git a/src/Schema/Content/AudioContent.php b/src/Schema/Content/AudioContent.php index a23db24c..cf7418c5 100644 --- a/src/Schema/Content/AudioContent.php +++ b/src/Schema/Content/AudioContent.php @@ -44,14 +44,17 @@ public function __construct( */ 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') ); } diff --git a/src/Schema/Content/BlobResourceContents.php b/src/Schema/Content/BlobResourceContents.php index 0d6016d5..ee126fbc 100644 --- a/src/Schema/Content/BlobResourceContents.php +++ b/src/Schema/Content/BlobResourceContents.php @@ -54,6 +54,13 @@ public static function fromArray(array $data): self 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); } diff --git a/src/Schema/Content/EmbeddedResource.php b/src/Schema/Content/EmbeddedResource.php index eb849858..76c4d679 100644 --- a/src/Schema/Content/EmbeddedResource.php +++ b/src/Schema/Content/EmbeddedResource.php @@ -62,7 +62,7 @@ public static function fromArray(array $data): self return new self( $resourceInstance, - isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, + Annotations::tryFromArray($data['annotations'] ?? null, 'EmbeddedResource'), ); } diff --git a/src/Schema/Content/PromptMessage.php b/src/Schema/Content/PromptMessage.php index 075d1136..923459d9 100644 --- a/src/Schema/Content/PromptMessage.php +++ b/src/Schema/Content/PromptMessage.php @@ -58,6 +58,9 @@ public static function fromArray(array $data): self $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 @@ public static function fromArray(array $data): self 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); } /** diff --git a/src/Schema/Content/SamplingMessage.php b/src/Schema/Content/SamplingMessage.php index 48aaa713..18c8877b 100644 --- a/src/Schema/Content/SamplingMessage.php +++ b/src/Schema/Content/SamplingMessage.php @@ -45,9 +45,15 @@ public static function fromArray(array $data): self 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), diff --git a/src/Schema/Content/TextContent.php b/src/Schema/Content/TextContent.php index 14a64024..b743af57 100644 --- a/src/Schema/Content/TextContent.php +++ b/src/Schema/Content/TextContent.php @@ -56,7 +56,7 @@ public static function fromArray(array $data): self return new self( $data['text'], - isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null + Annotations::tryFromArray($data['annotations'] ?? null, 'TextContent') ); } diff --git a/src/Schema/Content/TextResourceContents.php b/src/Schema/Content/TextResourceContents.php index 47ee31fd..9beff811 100644 --- a/src/Schema/Content/TextResourceContents.php +++ b/src/Schema/Content/TextResourceContents.php @@ -54,6 +54,13 @@ public static function fromArray(array $data): self 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); } diff --git a/src/Schema/Elicitation/ElicitationSchema.php b/src/Schema/Elicitation/ElicitationSchema.php index 1e4486ca..36d130af 100644 --- a/src/Schema/Elicitation/ElicitationSchema.php +++ b/src/Schema/Elicitation/ElicitationSchema.php @@ -35,6 +35,9 @@ public function __construct( } 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 @@ public static function fromArray(array $data): self $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'] ?? [], diff --git a/src/Schema/Extension/Apps/UiResourceContentMeta.php b/src/Schema/Extension/Apps/UiResourceContentMeta.php index e507d80b..666a7e99 100644 --- a/src/Schema/Extension/Apps/UiResourceContentMeta.php +++ b/src/Schema/Extension/Apps/UiResourceContentMeta.php @@ -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 @@ public function __construct( */ 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, diff --git a/src/Schema/Extension/Apps/UiResourceCsp.php b/src/Schema/Extension/Apps/UiResourceCsp.php index d487d725..e3550a67 100644 --- a/src/Schema/Extension/Apps/UiResourceCsp.php +++ b/src/Schema/Extension/Apps/UiResourceCsp.php @@ -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 @@ public function __construct( */ 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, diff --git a/src/Schema/Extension/Apps/UiToolMeta.php b/src/Schema/Extension/Apps/UiToolMeta.php index ecefa19e..235df2a4 100644 --- a/src/Schema/Extension/Apps/UiToolMeta.php +++ b/src/Schema/Extension/Apps/UiToolMeta.php @@ -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 @@ public function __construct( */ 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, ); } diff --git a/src/Schema/Icon.php b/src/Schema/Icon.php index b3cdd722..13929e03 100644 --- a/src/Schema/Icon.php +++ b/src/Schema/Icon.php @@ -65,8 +65,36 @@ public static function fromArray(array $data): self 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['mimeType'] ?? null, $data['sizes'] ?? null); + } + + /** + * Hydrates an "icons" list, rejecting entries that are not objects. + * + * @param array $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 new self($data['src'], $data['mimeTypes'] ?? null, $data['sizes'] ?? null); + return self::fromArray($icon); + }, + $icons, + ); } /** diff --git a/src/Schema/Implementation.php b/src/Schema/Implementation.php index 0216f051..214a38f8 100644 --- a/src/Schema/Implementation.php +++ b/src/Schema/Implementation.php @@ -57,7 +57,14 @@ public static function fromArray(array $data): self 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( diff --git a/src/Schema/ModelPreferences.php b/src/Schema/ModelPreferences.php index f2f30dbc..88cf5584 100644 --- a/src/Schema/ModelPreferences.php +++ b/src/Schema/ModelPreferences.php @@ -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 @@ public function __construct( */ 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 $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 */ diff --git a/src/Schema/Notification/CancelledNotification.php b/src/Schema/Notification/CancelledNotification.php index 14d4e592..4ae32576 100644 --- a/src/Schema/Notification/CancelledNotification.php +++ b/src/Schema/Notification/CancelledNotification.php @@ -48,6 +48,10 @@ protected static function fromParams(?array $params): 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); } diff --git a/src/Schema/Notification/LoggingMessageNotification.php b/src/Schema/Notification/LoggingMessageNotification.php index 29e00fca..3088f491 100644 --- a/src/Schema/Notification/LoggingMessageNotification.php +++ b/src/Schema/Notification/LoggingMessageNotification.php @@ -47,7 +47,14 @@ protected static function fromParams(?array $params): 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); diff --git a/src/Schema/Notification/ProgressNotification.php b/src/Schema/Notification/ProgressNotification.php index d3365a9f..15fc50ac 100644 --- a/src/Schema/Notification/ProgressNotification.php +++ b/src/Schema/Notification/ProgressNotification.php @@ -44,18 +44,27 @@ public static function getMethod(): string 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, ); } diff --git a/src/Schema/Prompt.php b/src/Schema/Prompt.php index 68ae773f..1f33e410 100644 --- a/src/Schema/Prompt.php +++ b/src/Schema/Prompt.php @@ -67,19 +67,34 @@ public static function fromArray(array $data): self } $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 ); } diff --git a/src/Schema/PromptArgument.php b/src/Schema/PromptArgument.php index 3ef45ca6..38fe3dc0 100644 --- a/src/Schema/PromptArgument.php +++ b/src/Schema/PromptArgument.php @@ -47,6 +47,13 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Invalid or missing "name" in PromptArgument data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in PromptArgument data.'); + } + if (isset($data['required']) && !\is_bool($data['required'])) { + throw new InvalidArgumentException('Invalid "required" in PromptArgument data.'); + } + return new self( name: $data['name'], description: $data['description'] ?? null, diff --git a/src/Schema/Request/CompletionCompleteRequest.php b/src/Schema/Request/CompletionCompleteRequest.php index 467bb97c..7ad0332c 100644 --- a/src/Schema/Request/CompletionCompleteRequest.php +++ b/src/Schema/Request/CompletionCompleteRequest.php @@ -45,8 +45,8 @@ protected static function fromParams(?array $params): static } $ref = match ($params['ref']['type'] ?? null) { - 'ref/prompt' => new PromptReference($params['ref']['name']), - 'ref/resource' => new ResourceReference($params['ref']['uri']), + 'ref/prompt' => new PromptReference(self::refString($params['ref'], 'name')), + 'ref/resource' => new ResourceReference(self::refString($params['ref'], 'uri')), default => throw new InvalidArgumentException('Invalid "ref" parameter for completion/complete.'), }; @@ -57,6 +57,18 @@ protected static function fromParams(?array $params): static return new self($ref, $params['argument']); } + /** + * @param array $ref + */ + private static function refString(array $ref, string $key): string + { + if (!isset($ref[$key]) || !\is_string($ref[$key])) { + throw new InvalidArgumentException(\sprintf('Missing or invalid "ref.%s" parameter for completion/complete.', $key)); + } + + return $ref[$key]; + } + /** * @return array{ * ref: PromptReference|ResourceReference, diff --git a/src/Schema/Request/CreateSamplingMessageRequest.php b/src/Schema/Request/CreateSamplingMessageRequest.php index 3014405e..71a7b27b 100644 --- a/src/Schema/Request/CreateSamplingMessageRequest.php +++ b/src/Schema/Request/CreateSamplingMessageRequest.php @@ -87,6 +87,9 @@ protected static function fromParams(?array $params): static $preferences = null; if (isset($params['preferences'])) { + if (!\is_array($params['preferences'])) { + throw new InvalidArgumentException('Invalid "preferences" parameter for sampling/createMessage.'); + } $preferences = ModelPreferences::fromArray($params['preferences']); } @@ -95,13 +98,37 @@ protected static function fromParams(?array $params): static $includeContext = SamplingContext::tryFrom($params['includeContext']); } + if (isset($params['systemPrompt']) && !\is_string($params['systemPrompt'])) { + throw new InvalidArgumentException('Invalid "systemPrompt" parameter for sampling/createMessage.'); + } + + if (isset($params['temperature']) && !\is_float($params['temperature']) && !\is_int($params['temperature'])) { + throw new InvalidArgumentException('Invalid "temperature" parameter for sampling/createMessage.'); + } + + if (isset($params['stopSequences'])) { + if (!\is_array($params['stopSequences'])) { + throw new InvalidArgumentException('Invalid "stopSequences" parameter for sampling/createMessage.'); + } + + foreach ($params['stopSequences'] as $stopSequence) { + if (!\is_string($stopSequence)) { + throw new InvalidArgumentException('Each entry in "stopSequences" must be a string for sampling/createMessage.'); + } + } + } + + if (isset($params['metadata']) && !\is_array($params['metadata'])) { + throw new InvalidArgumentException('Invalid "metadata" parameter for sampling/createMessage.'); + } + return new self( $messages, $params['maxTokens'], $preferences, $params['systemPrompt'] ?? null, $includeContext, - $params['temperature'] ?? null, + isset($params['temperature']) ? (float) $params['temperature'] : null, $params['stopSequences'] ?? null, $params['metadata'] ?? null, ); diff --git a/src/Schema/Request/InitializeRequest.php b/src/Schema/Request/InitializeRequest.php index 04db317c..f7b74601 100644 --- a/src/Schema/Request/InitializeRequest.php +++ b/src/Schema/Request/InitializeRequest.php @@ -42,17 +42,17 @@ public static function getMethod(): string protected static function fromParams(?array $params): static { - if (!isset($params['protocolVersion'])) { - throw new InvalidArgumentException('protocolVersion is required'); + if (!isset($params['protocolVersion']) || !\is_string($params['protocolVersion'])) { + throw new InvalidArgumentException('Missing or invalid "protocolVersion" parameter for initialize.'); } - if (!isset($params['capabilities'])) { - throw new InvalidArgumentException('capabilities is required'); + if (!isset($params['capabilities']) || !\is_array($params['capabilities'])) { + throw new InvalidArgumentException('Missing or invalid "capabilities" parameter for initialize.'); } $capabilities = ClientCapabilities::fromArray($params['capabilities']); - if (!isset($params['clientInfo'])) { - throw new InvalidArgumentException('clientInfo is required'); + if (!isset($params['clientInfo']) || !\is_array($params['clientInfo'])) { + throw new InvalidArgumentException('Missing or invalid "clientInfo" parameter for initialize.'); } $clientInfo = Implementation::fromArray($params['clientInfo']); diff --git a/src/Schema/Request/ListPromptsRequest.php b/src/Schema/Request/ListPromptsRequest.php index 8c627999..3f1cff0e 100644 --- a/src/Schema/Request/ListPromptsRequest.php +++ b/src/Schema/Request/ListPromptsRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ public static function getMethod(): string protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for prompts/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/ListResourceTemplatesRequest.php b/src/Schema/Request/ListResourceTemplatesRequest.php index 4ce4dfd9..1822c57e 100644 --- a/src/Schema/Request/ListResourceTemplatesRequest.php +++ b/src/Schema/Request/ListResourceTemplatesRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ public static function getMethod(): string protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for resources/templates/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/ListResourcesRequest.php b/src/Schema/Request/ListResourcesRequest.php index 30c00418..a8fe7727 100644 --- a/src/Schema/Request/ListResourcesRequest.php +++ b/src/Schema/Request/ListResourcesRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ public static function getMethod(): string protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for resources/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/ListToolsRequest.php b/src/Schema/Request/ListToolsRequest.php index 00af2863..83cf8875 100644 --- a/src/Schema/Request/ListToolsRequest.php +++ b/src/Schema/Request/ListToolsRequest.php @@ -11,6 +11,7 @@ namespace Mcp\Schema\Request; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Request; /** @@ -37,6 +38,10 @@ public static function getMethod(): string protected static function fromParams(?array $params): static { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for tools/list.'); + } + return new self($params['cursor'] ?? null); } diff --git a/src/Schema/Request/SetLogLevelRequest.php b/src/Schema/Request/SetLogLevelRequest.php index eb83e1c8..1a441ccb 100644 --- a/src/Schema/Request/SetLogLevelRequest.php +++ b/src/Schema/Request/SetLogLevelRequest.php @@ -43,7 +43,11 @@ protected static function fromParams(?array $params): static throw new InvalidArgumentException('Missing or invalid "level" parameter for "logging/setLevel".'); } - return new self(LoggingLevel::from($params['level'])); + if (null === $level = LoggingLevel::tryFrom($params['level'])) { + throw new InvalidArgumentException(\sprintf('Invalid "level" parameter "%s" for "logging/setLevel".', $params['level'])); + } + + return new self($level); } /** diff --git a/src/Schema/ResourceDefinition.php b/src/Schema/ResourceDefinition.php index a3999667..ca9d0e65 100644 --- a/src/Schema/ResourceDefinition.php +++ b/src/Schema/ResourceDefinition.php @@ -88,9 +88,18 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Invalid or missing "name" in ResourceDefinition data.'); } - if (!empty($data['_meta']) && !\is_array($data['_meta'])) { + if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in ResourceDefinition data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in ResourceDefinition data.'); + } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" in ResourceDefinition data.'); + } + if (isset($data['size']) && !\is_int($data['size'])) { + throw new InvalidArgumentException('Invalid "size" in ResourceDefinition data; expected an integer.'); + } return new self( uri: $data['uri'], @@ -98,9 +107,9 @@ public static function fromArray(array $data): self title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, - annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, - size: isset($data['size']) ? (int) $data['size'] : null, - icons: isset($data['icons']) && \is_array($data['icons']) ? array_map(Icon::fromArray(...), $data['icons']) : null, + annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceDefinition'), + size: $data['size'] ?? null, + icons: isset($data['icons']) && \is_array($data['icons']) ? Icon::listFromArray($data['icons'], 'ResourceDefinition') : null, meta: isset($data['_meta']) ? $data['_meta'] : null ); } diff --git a/src/Schema/ResourceTemplate.php b/src/Schema/ResourceTemplate.php index 46f173ca..a26e8b92 100644 --- a/src/Schema/ResourceTemplate.php +++ b/src/Schema/ResourceTemplate.php @@ -81,9 +81,15 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Invalid or missing "name" in ResourceTemplate data.'); } - if (!empty($data['_meta']) && !\is_array($data['_meta'])) { + if (isset($data['_meta']) && !\is_array($data['_meta'])) { throw new InvalidArgumentException('Invalid "_meta" in ResourceTemplate data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in ResourceTemplate data.'); + } + if (isset($data['mimeType']) && !\is_string($data['mimeType'])) { + throw new InvalidArgumentException('Invalid "mimeType" in ResourceTemplate data.'); + } return new self( uriTemplate: $data['uriTemplate'], @@ -91,7 +97,7 @@ public static function fromArray(array $data): self title: isset($data['title']) && \is_string($data['title']) ? $data['title'] : null, description: $data['description'] ?? null, mimeType: $data['mimeType'] ?? null, - annotations: isset($data['annotations']) ? Annotations::fromArray($data['annotations']) : null, + annotations: Annotations::tryFromArray($data['annotations'] ?? null, 'ResourceTemplate'), meta: isset($data['_meta']) ? $data['_meta'] : null ); } diff --git a/src/Schema/Result/CallToolResult.php b/src/Schema/Result/CallToolResult.php index bfbc9fab..c82a6dd0 100644 --- a/src/Schema/Result/CallToolResult.php +++ b/src/Schema/Result/CallToolResult.php @@ -95,15 +95,30 @@ public static function fromArray(array $data): self $contents = []; foreach ($data['content'] as $item) { - $contents[] = match ($item['type'] ?? null) { + $type = \is_array($item) ? $item['type'] ?? null : null; + if (!\is_string($type)) { + throw new InvalidArgumentException('Missing or invalid content "type" in CallToolResult data.'); + } + + $contents[] = match ($type) { 'text' => TextContent::fromArray($item), 'image' => ImageContent::fromArray($item), 'audio' => AudioContent::fromArray($item), 'resource' => EmbeddedResource::fromArray($item), - default => throw new InvalidArgumentException(\sprintf('Invalid content type in CallToolResult data: "%s".', $item['type'] ?? null)), + default => throw new InvalidArgumentException(\sprintf('Invalid content type in CallToolResult data: "%s".', $type)), }; } + if (isset($data['isError']) && !\is_bool($data['isError'])) { + throw new InvalidArgumentException('Invalid "isError" in CallToolResult data.'); + } + if (isset($data['structuredContent']) && !\is_array($data['structuredContent'])) { + throw new InvalidArgumentException('Invalid "structuredContent" in CallToolResult data.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in CallToolResult data.'); + } + return new self( $contents, $data['isError'] ?? false, diff --git a/src/Schema/Result/CompletionCompleteResult.php b/src/Schema/Result/CompletionCompleteResult.php index d7fa4c4b..43c9fed4 100644 --- a/src/Schema/Result/CompletionCompleteResult.php +++ b/src/Schema/Result/CompletionCompleteResult.php @@ -67,6 +67,18 @@ public function jsonSerialize(): array public static function fromArray(array $data): self { $completion = $data['completion'] ?? []; + if (!\is_array($completion)) { + throw new InvalidArgumentException('Invalid "completion" in CompletionCompleteResult data.'); + } + if (isset($completion['values']) && !\is_array($completion['values'])) { + throw new InvalidArgumentException('Invalid "completion.values" in CompletionCompleteResult data.'); + } + if (isset($completion['total']) && !\is_int($completion['total'])) { + throw new InvalidArgumentException('Invalid "completion.total" in CompletionCompleteResult data.'); + } + if (isset($completion['hasMore']) && !\is_bool($completion['hasMore'])) { + throw new InvalidArgumentException('Invalid "completion.hasMore" in CompletionCompleteResult data.'); + } return new self( $completion['values'] ?? [], diff --git a/src/Schema/Result/CreateSamplingMessageResult.php b/src/Schema/Result/CreateSamplingMessageResult.php index 986d6291..8eb4b134 100644 --- a/src/Schema/Result/CreateSamplingMessageResult.php +++ b/src/Schema/Result/CreateSamplingMessageResult.php @@ -58,7 +58,10 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Missing or invalid "model" in CreateSamplingMessageResult data.'); } - $role = Role::from($data['role']); + if (null === $role = Role::tryFrom($data['role'])) { + throw new InvalidArgumentException(\sprintf('Invalid "role" value "%s" in CreateSamplingMessageResult data.', $data['role'])); + } + $contentPayload = $data['content']; $content = self::hydrateContent($contentPayload); diff --git a/src/Schema/Result/ElicitResult.php b/src/Schema/Result/ElicitResult.php index d667374d..70415959 100644 --- a/src/Schema/Result/ElicitResult.php +++ b/src/Schema/Result/ElicitResult.php @@ -44,7 +44,10 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Missing or invalid "action" in ElicitResult data.'); } - $action = ElicitAction::from($data['action']); + if (null === $action = ElicitAction::tryFrom($data['action'])) { + throw new InvalidArgumentException(\sprintf('Invalid "action" value "%s" in ElicitResult data.', $data['action'])); + } + $content = isset($data['content']) && \is_array($data['content']) ? $data['content'] : null; if (ElicitAction::Accept === $action && null === $content) { diff --git a/src/Schema/Result/GetPromptResult.php b/src/Schema/Result/GetPromptResult.php index 40277f8f..0083a83e 100644 --- a/src/Schema/Result/GetPromptResult.php +++ b/src/Schema/Result/GetPromptResult.php @@ -51,8 +51,16 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Missing or invalid "messages" array in GetPromptResult data.'); } + if (isset($data['description']) && !\is_string($data['description'])) { + throw new InvalidArgumentException('Invalid "description" in GetPromptResult data.'); + } + $messages = []; foreach ($data['messages'] as $message) { + if (!\is_array($message)) { + throw new InvalidArgumentException('Each entry in "messages" of GetPromptResult data must be an array.'); + } + $messages[] = PromptMessage::fromArray($message); } diff --git a/src/Schema/Result/InitializeResult.php b/src/Schema/Result/InitializeResult.php index 5c184d63..e80c28ca 100644 --- a/src/Schema/Result/InitializeResult.php +++ b/src/Schema/Result/InitializeResult.php @@ -64,6 +64,13 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Missing or invalid "serverInfo".'); } + if (isset($data['instructions']) && !\is_string($data['instructions'])) { + throw new InvalidArgumentException('Invalid "instructions" in InitializeResult data.'); + } + if (isset($data['_meta']) && !\is_array($data['_meta'])) { + throw new InvalidArgumentException('Invalid "_meta" in InitializeResult data.'); + } + return new self( ServerCapabilities::fromArray($data['capabilities']), Implementation::fromArray($data['serverInfo']), diff --git a/src/Schema/Result/ListPromptsResult.php b/src/Schema/Result/ListPromptsResult.php index 7b1b3823..fd164c45 100644 --- a/src/Schema/Result/ListPromptsResult.php +++ b/src/Schema/Result/ListPromptsResult.php @@ -49,8 +49,21 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Missing or invalid "prompts" array in ListPromptsResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListPromptsResult data.'); + } + return new self( - array_map(static fn (array $prompt) => Prompt::fromArray($prompt), $data['prompts']), + array_map( + static function (mixed $entry): Prompt { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "prompts" of ListPromptsResult data must be an array.'); + } + + return Prompt::fromArray($entry); + }, + $data['prompts'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Result/ListResourceTemplatesResult.php b/src/Schema/Result/ListResourceTemplatesResult.php index 1b8ddb2b..e0b71146 100644 --- a/src/Schema/Result/ListResourceTemplatesResult.php +++ b/src/Schema/Result/ListResourceTemplatesResult.php @@ -49,8 +49,21 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Missing or invalid "resourceTemplates" array in ListResourceTemplatesResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListResourceTemplatesResult data.'); + } + return new self( - array_map(static fn (array $resourceTemplate) => ResourceTemplate::fromArray($resourceTemplate), $data['resourceTemplates']), + array_map( + static function (mixed $entry): ResourceTemplate { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "resourceTemplates" of ListResourceTemplatesResult data must be an array.'); + } + + return ResourceTemplate::fromArray($entry); + }, + $data['resourceTemplates'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Result/ListResourcesResult.php b/src/Schema/Result/ListResourcesResult.php index 0f338caa..63066b06 100644 --- a/src/Schema/Result/ListResourcesResult.php +++ b/src/Schema/Result/ListResourcesResult.php @@ -49,8 +49,21 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Missing or invalid "resources" array in ListResourcesResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListResourcesResult data.'); + } + return new self( - array_map(static fn (array $resource) => ResourceDefinition::fromArray($resource), $data['resources']), + array_map( + static function (mixed $entry): ResourceDefinition { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "resources" of ListResourcesResult data must be an array.'); + } + + return ResourceDefinition::fromArray($entry); + }, + $data['resources'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Result/ListToolsResult.php b/src/Schema/Result/ListToolsResult.php index 7b08a0de..c6ba6c84 100644 --- a/src/Schema/Result/ListToolsResult.php +++ b/src/Schema/Result/ListToolsResult.php @@ -49,8 +49,21 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Missing or invalid "tools" array in ListToolsResult data.'); } + if (isset($data['nextCursor']) && !\is_string($data['nextCursor'])) { + throw new InvalidArgumentException('Invalid "nextCursor" in ListToolsResult data.'); + } + return new self( - array_map(static fn (array $tool) => Tool::fromArray($tool), $data['tools']), + array_map( + static function (mixed $entry): Tool { + if (!\is_array($entry)) { + throw new InvalidArgumentException('Each entry in "tools" of ListToolsResult data must be an array.'); + } + + return Tool::fromArray($entry); + }, + $data['tools'], + ), $data['nextCursor'] ?? null ); } diff --git a/src/Schema/Root.php b/src/Schema/Root.php index 5f6862d4..3c20e9e7 100644 --- a/src/Schema/Root.php +++ b/src/Schema/Root.php @@ -54,6 +54,10 @@ public static function fromArray(array $data): self throw new InvalidArgumentException('Invalid or missing "uri" in Root data.'); } + if (isset($data['name']) && !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid "name" in Root data.'); + } + return new self($data['uri'], $data['name'] ?? null); } diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 18178044..6d527675 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -103,7 +103,7 @@ public static function fromArray(array $data): self inputSchema: $inputSchema, description: isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, annotations: isset($data['annotations']) && \is_array($data['annotations']) ? ToolAnnotations::fromArray($data['annotations']) : null, - 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'], 'Tool') : null, meta: isset($data['_meta']) && \is_array($data['_meta']) ? $data['_meta'] : null, outputSchema: $outputSchema, ); diff --git a/src/Schema/ToolAnnotations.php b/src/Schema/ToolAnnotations.php index 783d9599..c4f27746 100644 --- a/src/Schema/ToolAnnotations.php +++ b/src/Schema/ToolAnnotations.php @@ -11,6 +11,8 @@ namespace Mcp\Schema; +use Mcp\Exception\InvalidArgumentException; + /** * Additional properties describing a Tool to clients. * NOTE: all properties in ToolAnnotations are hints. @@ -48,6 +50,16 @@ public function __construct( */ public static function fromArray(array $data): self { + if (isset($data['title']) && !\is_string($data['title'])) { + throw new InvalidArgumentException('Invalid "title" in ToolAnnotations data.'); + } + + foreach (['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as $hint) { + if (isset($data[$hint]) && !\is_bool($data[$hint])) { + throw new InvalidArgumentException(\sprintf('Invalid "%s" in ToolAnnotations data; expected a boolean.', $hint)); + } + } + return new self( $data['title'] ?? null, $data['readOnlyHint'] ?? null, diff --git a/tests/Unit/JsonRpc/MalformedInputTest.php b/tests/Unit/JsonRpc/MalformedInputTest.php new file mode 100644 index 00000000..cb8d1ad3 --- /dev/null +++ b/tests/Unit/JsonRpc/MalformedInputTest.php @@ -0,0 +1,83 @@ + + */ +final class MalformedInputTest extends TestCase +{ + /** + * @return iterable + */ + public static function provideMalformedPayloads(): iterable + { + yield 'method is an object' => ['{"jsonrpc":"2.0","id":1,"method":{"evil":true}}']; + yield 'method is an array' => ['{"jsonrpc":"2.0","id":1,"method":[1,2,3]}']; + + yield 'initialize with array protocolVersion' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":[1],"capabilities":{},"clientInfo":{"name":"x","version":"1"}}}']; + yield 'initialize with string capabilities' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":"x","clientInfo":{"name":"x","version":"1"}}}']; + yield 'initialize with string clientInfo' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":"x"}}']; + yield 'initialize with non-array icons entry' => ['{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"x","version":"1","icons":["x"]}}}']; + + yield 'completion ref/prompt with array name' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":[1]},"argument":{"name":"x","value":"y"}}}']; + yield 'completion ref/prompt without name' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/prompt"},"argument":{"name":"x","value":"y"}}}']; + yield 'completion ref/resource with object uri' => ['{"jsonrpc":"2.0","id":1,"method":"completion/complete","params":{"ref":{"type":"ref/resource","uri":{}},"argument":{"name":"x","value":"y"}}}']; + + yield 'setLevel with unknown enum value' => ['{"jsonrpc":"2.0","id":1,"method":"logging/setLevel","params":{"level":"not-a-real-level"}}']; + yield 'logging notification with unknown enum value' => ['{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"nope","data":"x"}}']; + + yield 'tools/list with array cursor' => ['{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"cursor":[1]}}']; + yield 'cancelled notification with array reason' => ['{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1,"reason":[1]}}']; + yield 'progress notification with array total' => ['{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t","progress":1,"total":[1]}}']; + + yield 'sampling with string preferences' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"preferences":"x"}}']; + yield 'sampling with array systemPrompt' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"systemPrompt":[1]}}']; + yield 'sampling with unknown role' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[{"role":"nope","content":{"type":"text","text":"x"}}],"maxTokens":1}}']; + yield 'sampling with array content type' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[{"role":"user","content":{"type":[1],"text":"x"}}],"maxTokens":1}}']; + yield 'sampling with non-string stopSequence' => ['{"jsonrpc":"2.0","id":1,"method":"sampling/createMessage","params":{"messages":[],"maxTokens":1,"stopSequences":[[]]}}']; + + yield 'elicitation with string required' => ['{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"message":"m","requestedSchema":{"type":"object","properties":{"a":{"type":"string","title":"T"}},"required":"a"}}}']; + yield 'elicitation with array in required' => ['{"jsonrpc":"2.0","id":1,"method":"elicitation/create","params":{"message":"m","requestedSchema":{"type":"object","properties":{"a":{"type":"string","title":"T"}},"required":[[]]}}}']; + } + + #[DataProvider('provideMalformedPayloads')] + #[TestDox('Malformed payload is reported as invalid input: $_dataName')] + public function testMalformedPayloadIsReportedAsInvalidInput(string $payload): void + { + $results = MessageFactory::make()->create($payload); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + } + + #[TestDox('A malformed message in a batch does not discard the valid ones')] + public function testMalformedMessageInBatchDoesNotDiscardValidMessages(): void + { + $payload = '[{"jsonrpc":"2.0","id":1,"method":"tools/list"},{"jsonrpc":"2.0","id":2,"method":{}}]'; + + $results = MessageFactory::make()->create($payload); + + $this->assertCount(2, $results); + $this->assertInstanceOf(\Mcp\Schema\Request\ListToolsRequest::class, $results[0]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); + } +} diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index 291b6c5e..441a500a 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -21,6 +21,7 @@ use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Request\PingRequest; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class MessageFactoryTest extends TestCase @@ -452,6 +453,41 @@ public function testBatchElementMustBeObject(): void $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); } + /** + * @return iterable + */ + public static function provideNonStringMethods(): iterable + { + yield 'object' => ['{"evil": true}']; + yield 'array' => ['[1, 2, 3]']; + yield 'int' => ['5']; + yield 'bool' => ['true']; + } + + #[DataProvider('provideNonStringMethods')] + public function testNonStringMethodIsRejected(string $method): void + { + $results = $this->factory->create(\sprintf('{"jsonrpc": "2.0", "id": 1, "method": %s}', $method)); + + $this->assertCount(1, $results); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[0]); + $this->assertStringContainsString('"method" must be a string', $results[0]->getMessage()); + } + + public function testBatchWithNonStringMethodStillYieldsTheValidMessages(): void + { + $json = '[ + {"jsonrpc": "2.0", "method": "ping", "id": 1}, + {"jsonrpc": "2.0", "method": {}, "id": 2} + ]'; + + $results = $this->factory->create($json); + + $this->assertCount(2, $results); + $this->assertInstanceOf(PingRequest::class, $results[0]); + $this->assertInstanceOf(InvalidInputMessageException::class, $results[1]); + } + public function testLeadingWhitespaceObjectIsParsedAsSingleMessage(): void { $json = " \n {\"jsonrpc\": \"2.0\", \"method\": \"ping\", \"id\": 1}"; diff --git a/tests/Unit/Schema/Result/ElicitResultTest.php b/tests/Unit/Schema/Result/ElicitResultTest.php index 6ffd15cf..62091a50 100644 --- a/tests/Unit/Schema/Result/ElicitResultTest.php +++ b/tests/Unit/Schema/Result/ElicitResultTest.php @@ -85,7 +85,8 @@ public function testFromArrayWithMissingAction(): void public function testFromArrayWithInvalidAction(): void { - $this->expectException(\ValueError::class); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "action" value "invalid"'); ElicitResult::fromArray(['action' => 'invalid']); } From e44dcf9c5204be2bc38df26ed4f03b9e9ac62d72 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 11 Aug 2026 00:21:43 +0200 Subject: [PATCH 3/3] Stop leaking handler exception messages to the client --- src/Server/Protocol.php | 2 +- tests/Unit/Server/ProtocolTest.php | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 491b11f0..5a4e358f 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -313,7 +313,7 @@ private function handleRequest(TransportInterface $transport, Request $request, } catch (\Throwable $e) { $this->logger->error(\sprintf('Uncaught exception: %s', $e->getMessage()), ['exception' => $e]); - $error = Error::forInternalError($e->getMessage(), $request->getId()); + $error = Error::forInternalError(self::INTERNAL_ERROR_MESSAGE, $request->getId()); $errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e)); $error = $errorEvent->getError(); diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index b75c36db..b5f836ea 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -670,7 +670,8 @@ public function testHandlerUnexpectedExceptionReturnsInternalError(): void $message = json_decode($outgoing[0]['message'], true); $this->assertArrayHasKey('error', $message); $this->assertEquals(Error::INTERNAL_ERROR, $message['error']['code']); - $this->assertStringContainsString('Unexpected error', $message['error']['message']); + $this->assertSame('Internal server error.', $message['error']['message']); + $this->assertStringNotContainsString('Unexpected error', $message['error']['message']); } #[TestDox('Notification handler exceptions are caught and logged')]