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..16163a6b5 --- /dev/null +++ b/src/Exception/UnknownMethodException.php @@ -0,0 +1,19 @@ + + */ +final class UnknownMethodException extends InvalidInputMessageException +{ +} diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 2bf934457..63d5dc117 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; @@ -95,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 @@ -177,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 { @@ -187,6 +189,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 {