From e43b105c44f37a2607e9a019915c08cc6c6c43cf Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 13:32:28 +0100 Subject: [PATCH 1/2] [Server] Parse decoded messages without a JSON round trip Add MessageFactory::fromArray() and UnknownMethodException, so stateless dispatch stops re-encoding its input and matching on an error message. --- CHANGELOG.md | 1 + src/Exception/UnknownMethodException.php | 19 ++++++++++++ src/JsonRpc/MessageFactory.php | 3 +- src/Server/Stateless/StatelessProtocol.php | 13 +++------ tests/Unit/JsonRpc/MessageFactoryTest.php | 3 +- .../Stateless/StatelessProtocolTest.php | 29 +++++++++++++++++++ 6 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 src/Exception/UnknownMethodException.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 823238301..2fbbac663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ All notable changes to `mcp/sdk` will be documented in this file. ### JSON-RPC +* Add `UnknownMethodException`, a subclass of `InvalidInputMessageException` for a method the factory has not registered. * [BC Break] `MessageFactory::create()` returns one `MessageInterface` and throws `InvalidInputMessageException` for invalid input, instead of returning a list holding messages and exceptions. * [BC Break] Remove `MessageFactory::DEFAULT_MAX_BATCH_SIZE` and the `$maxBatchSize` parameter of `MessageFactory::__construct()` and `MessageFactory::make()`, whose first parameter is now `$additional`. diff --git a/src/Exception/UnknownMethodException.php b/src/Exception/UnknownMethodException.php new file mode 100644 index 000000000..5f9a44776 --- /dev/null +++ b/src/Exception/UnknownMethodException.php @@ -0,0 +1,19 @@ + + */ +class UnknownMethodException extends InvalidInputMessageException +{ +} diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 2bf934457..4ce4bf71d 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -13,6 +13,7 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\InvalidInputMessageException; +use Mcp\Exception\UnknownMethodException; use Mcp\Schema; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\MessageInterface; @@ -187,6 +188,6 @@ private function findMessageClassByMethod(string $method): string } } - throw new InvalidInputMessageException(\sprintf('Unknown method "%s".', $method)); + throw new UnknownMethodException(\sprintf('Unknown method "%s".', $method)); } } diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 6e8e5137b..b4dbd446a 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -19,6 +19,7 @@ use Mcp\Exception\MissingRequestMetaException; use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\RequestStateException; +use Mcp\Exception\UnknownMethodException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; @@ -458,16 +459,10 @@ private function dispatch(string $method, ?array $params, string $body, RequestM { try { $request = $this->messageFactory->create($body); + } catch (UnknownMethodException) { + return StatelessResult::error($this->unknownMethod($method, $id), 404); } catch (InvalidInputMessageException $e) { - // An unknown method (-32601) tells the client to stop asking; a malformed request (-32600) does not. - $unknownMethod = \sprintf('Unknown method "%s".', $method) === $e->getMessage(); - - return StatelessResult::error( - $unknownMethod - ? $this->unknownMethod($method, $id) - : Error::forInvalidRequest($e->getMessage(), $id), - $unknownMethod ? 404 : 400, - ); + return StatelessResult::error(Error::forInvalidRequest($e->getMessage(), $id), 400); } catch (\Throwable $e) { $this->logger->warning('Rejected an unparseable modern-era request.', ['method' => $method, 'exception' => $e]); diff --git a/tests/Unit/JsonRpc/MessageFactoryTest.php b/tests/Unit/JsonRpc/MessageFactoryTest.php index f027b78f9..0ba0dc5d8 100644 --- a/tests/Unit/JsonRpc/MessageFactoryTest.php +++ b/tests/Unit/JsonRpc/MessageFactoryTest.php @@ -12,6 +12,7 @@ namespace Mcp\Tests\Unit\JsonRpc; use Mcp\Exception\InvalidInputMessageException; +use Mcp\Exception\UnknownMethodException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; @@ -280,7 +281,7 @@ public function testUnknownMethod(): void { $json = '{"jsonrpc": "2.0", "method": "unknown/method", "id": 1}'; - $this->expectException(InvalidInputMessageException::class); + $this->expectException(UnknownMethodException::class); $this->expectExceptionMessage('Unknown method'); $this->factory->create($json); diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 058eb7ce5..36fa66410 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -15,6 +15,7 @@ use Mcp\Event\RequestEvent; use Mcp\Event\ResponseEvent; use Mcp\Exception\MissingRequiredClientCapabilityException; +use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\Elicitation\ElicitationSchema; @@ -22,6 +23,7 @@ use Mcp\Schema\Enum\CacheScope; use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; @@ -38,6 +40,7 @@ use Mcp\Schema\ServerCapabilities; use Mcp\Server; use Mcp\Server\Authorization\AccessToken; +use Mcp\Server\Configuration; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\RequestContext; use Mcp\Server\Session\SessionInterface; @@ -49,6 +52,7 @@ use Mcp\Server\Subscription\InMemoryNotificationBus; use Mcp\Server\Suspension\NotificationSuspension; use Mcp\Server\Wire\CachePolicy; +use Mcp\Tests\Unit\Fixtures\ThrowingRequest; use Mcp\Tests\Unit\Server\Extension\ThingExtension; use Mcp\Tests\Unit\Server\Extension\UnservedThingExtension; use PHPUnit\Framework\Attributes\DataProvider; @@ -319,6 +323,31 @@ public function testRemovedMethodsAreUnknown(string $method, array $params): voi $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); } + #[TestDox('a known method with malformed params is an invalid request, not an unknown method')] + public function testMalformedRequestIsNotAnUnknownMethod(): void + { + $answer = self::call(self::protocol(), 'tools/list', ['cursor' => [1]]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::INVALID_REQUEST, $answer['body']['error']['code']); + } + + #[TestDox('a request class that crashes while hydrating is answered without leaking the failure')] + public function testCrashWhileHydratingIsAnswered(): void + { + $protocol = new StatelessProtocol( + [], + new MessageFactory([ThrowingRequest::class]), + new Configuration(new Implementation('test-server', '1.0.0'), new ServerCapabilities()), + ); + + $answer = self::call($protocol, 'test/throwing'); + + $this->assertSame(404, $answer['status']); + $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); + $this->assertStringNotContainsString('must not leak', json_encode($answer['body'], \JSON_THROW_ON_ERROR)); + } + #[TestDox('a handshake-era client opening with "initialize" is told which revisions are served')] public function testInitializeNamesTheServedRevisions(): void { From 74a5bf2cba5333c3a93c6decb656a192491a5e28 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sun, 8 Nov 2026 14:28:38 +0100 Subject: [PATCH 2/2] [Server] Make `UnknownMethodException` final and document it on the factory --- src/Exception/UnknownMethodException.php | 2 +- src/JsonRpc/MessageFactory.php | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Exception/UnknownMethodException.php b/src/Exception/UnknownMethodException.php index 5f9a44776..16163a6b5 100644 --- a/src/Exception/UnknownMethodException.php +++ b/src/Exception/UnknownMethodException.php @@ -14,6 +14,6 @@ /** * @author Christopher Hertel */ -class UnknownMethodException extends InvalidInputMessageException +final class UnknownMethodException extends InvalidInputMessageException { } diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 4ce4bf71d..63d5dc117 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -96,6 +96,7 @@ public static function make(array $additional = []): self * Creates a message object from JSON input. * * @throws \JsonException When the input string is not valid JSON + * @throws UnknownMethodException When the method is not registered * @throws InvalidInputMessageException When the input is not a valid message */ public function create(string $input): MessageInterface @@ -178,7 +179,7 @@ private function createMessage(\stdClass|array $message): MessageInterface * * @return class-string|class-string * - * @throws InvalidInputMessageException + * @throws UnknownMethodException */ private function findMessageClassByMethod(string $method): string {