diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0b7062..ddca338c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] Keep the JSON types of `tools/call` arguments, so validation sees what the client sent: `{}` and `{"0":"x"}` are objects, `[]` and `["x"]` are arrays. Nested objects in `CallToolRequest::$arguments` and in the arguments of `ToolHandlerInterface::execute()` are `\stdClass`, as they are for parameters without a type or typed `mixed`, `object` or `\stdClass`; parameters typed `array` still receive arrays at every level. `array` parameters generate `{"type": "object"}`, and `array` parameters accept a JSON array or object. * [BC Break] Reject `tools/call` whose `arguments` is not a JSON object, including `[]`, with `-32602`, and one whose message has a member name starting with a NUL byte with `-32600`. * [BC Break] Answer params that do not fit a known method (e.g. `tools/call` without `name`) with `-32602` instead of `-32600`, as JSON-RPC requires. `Request::fromArray()` throws the new `InvalidParamsException`, a subclass of `InvalidArgumentException`, and `InvalidInputMessageException::isInvalidParams()` tells the protocols which code to send. + * Fix a handshake-era request for a method the SDK does not know being answered with `-32600`: it gets `-32601`, as JSON-RPC requires and as the `2026-07-28` era already answers. * [BC Break] Answer an argument that cannot be cast to its parameter type with `-32602` instead of `-32603`, for tools, prompts and resources. The message names the parameter. A `string` parameter refuses a list or an object instead of receiving `"Array"`, and an int-backed enum refuses a value with a fraction instead of truncating it. A missing required argument of a prompt, or of a tool that gets past schema validation, gets `-32602` too. * [BC Break] Modern tools with an `outputSchema` send and validate a null result as structured content, with JSON text `null` alongside it. A tool whose schema does not allow null, including a `void` tool, returns a tool error instead of the text `(null)`. `ToolReference::extractStructuredContent()` returns `StructuredContent::Absent` when there is nothing to send. * [BC Break] `ReferenceHandler` resolves a string handler that is not an invokable class from the container by service id. The container goes first, so a function handler no longer runs if the container has an entry of that name. diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 90c40e81..5dcbbc12 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -12,6 +12,7 @@ namespace Mcp\Server; use Mcp\Schema\Exception\InvalidInputMessageException; +use Mcp\Schema\Exception\UnknownMethodException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\MessageInterface; use Mcp\Schema\JsonRpc\Notification; @@ -376,9 +377,11 @@ private function handleInvalidMessage(TransportInterface $transport, InvalidInpu { $this->logger->warning('Failed to create message.', ['exception' => $exception]); - $error = $exception->isInvalidParams() - ? Error::forInvalidParams($exception->getMessage(), $exception->getRequestId()) - : Error::forInvalidRequest($exception->getMessage(), $exception->getRequestId()); + $error = match (true) { + $exception instanceof UnknownMethodException => Error::forMethodNotFound($exception->getMessage(), $exception->getRequestId()), + $exception->isInvalidParams() => Error::forInvalidParams($exception->getMessage(), $exception->getRequestId()), + default => Error::forInvalidRequest($exception->getMessage(), $exception->getRequestId()), + }; $this->sendResponse($transport, $error, $session, $context); } diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index 292d9acc..aba2e47d 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -959,6 +959,35 @@ public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void $this->assertStringContainsString('No handler found', $message['error']['message']); } + #[TestDox('Request for a method the SDK does not know returns method not found error')] + public function testUnknownMethodReturnsMethodNotFoundError(): void + { + $session = $this->createMock(SessionInterface::class); + + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); + + $transport = new RecordingTransport(); + + $protocol = new Protocol( + requestHandlers: [], + notificationHandlers: [], + messageFactory: MessageFactory::make(), + sessionManager: $this->sessionManager, + ); + + $protocol->processInput( + $transport, + '{"jsonrpc": "2.0", "id": 7, "method": "nonexistent/method", "params": {}}', + Uuid::v4() + ); + + $this->assertCount(1, $transport->sent); + $message = json_decode($transport->sent[0]['message'], true); + $this->assertSame(Error::METHOD_NOT_FOUND, $message['error']['code']); + $this->assertSame(7, $message['id']); + } + #[TestDox('Handler throwing InvalidArgumentException returns invalid params error')] public function testHandlerInvalidArgumentReturnsInvalidParamsError(): void { diff --git a/tests/Unit/Server/Transport/StdioDualEraTest.php b/tests/Unit/Server/Transport/StdioDualEraTest.php index 61907ca4..02325e1d 100644 --- a/tests/Unit/Server/Transport/StdioDualEraTest.php +++ b/tests/Unit/Server/Transport/StdioDualEraTest.php @@ -426,7 +426,7 @@ public function testCustomDiscoverHandlerIsNotReachedOverTheHandshake(): void ['jsonrpc' => '2.0', 'id' => 2, 'method' => 'server/discover'], ]); - $this->assertSame(Error::INVALID_REQUEST, $answers[2]['error']['code']); + $this->assertSame(Error::METHOD_NOT_FOUND, $answers[2]['error']['code']); $this->assertArrayNotHasKey('result', $answers[2]); }