Skip to content

Commit cba0121

Browse files
authored
[Server] Add UnknownMethodException instead of matching on the error message (#681)
* [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. * [Server] Make `UnknownMethodException` final and document it on the factory
1 parent 3700254 commit cba0121

6 files changed

Lines changed: 59 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
5656

5757
### JSON-RPC
5858

59+
* Add `UnknownMethodException`, a subclass of `InvalidInputMessageException` for a method the factory has not registered.
5960
* [BC Break] `MessageFactory::create()` returns one `MessageInterface` and throws `InvalidInputMessageException` for invalid input, instead of returning a list holding messages and exceptions.
6061
* [BC Break] Remove `MessageFactory::DEFAULT_MAX_BATCH_SIZE` and the `$maxBatchSize` parameter of `MessageFactory::__construct()` and `MessageFactory::make()`, whose first parameter is now `$additional`.
6162

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Exception;
13+
14+
/**
15+
* @author Christopher Hertel <mail@christopher-hertel.de>
16+
*/
17+
final class UnknownMethodException extends InvalidInputMessageException
18+
{
19+
}

‎src/JsonRpc/MessageFactory.php‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Mcp\Exception\InvalidArgumentException;
1515
use Mcp\Exception\InvalidInputMessageException;
16+
use Mcp\Exception\UnknownMethodException;
1617
use Mcp\Schema;
1718
use Mcp\Schema\JsonRpc\Error;
1819
use Mcp\Schema\JsonRpc\MessageInterface;
@@ -95,6 +96,7 @@ public static function make(array $additional = []): self
9596
* Creates a message object from JSON input.
9697
*
9798
* @throws \JsonException When the input string is not valid JSON
99+
* @throws UnknownMethodException When the method is not registered
98100
* @throws InvalidInputMessageException When the input is not a valid message
99101
*/
100102
public function create(string $input): MessageInterface
@@ -177,7 +179,7 @@ private function createMessage(\stdClass|array $message): MessageInterface
177179
*
178180
* @return class-string<Request>|class-string<Notification>
179181
*
180-
* @throws InvalidInputMessageException
182+
* @throws UnknownMethodException
181183
*/
182184
private function findMessageClassByMethod(string $method): string
183185
{
@@ -187,6 +189,6 @@ private function findMessageClassByMethod(string $method): string
187189
}
188190
}
189191

190-
throw new InvalidInputMessageException(\sprintf('Unknown method "%s".', $method));
192+
throw new UnknownMethodException(\sprintf('Unknown method "%s".', $method));
191193
}
192194
}

‎src/Server/Stateless/StatelessProtocol.php‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Mcp\Exception\MissingRequestMetaException;
2020
use Mcp\Exception\MissingRequiredClientCapabilityException;
2121
use Mcp\Exception\RequestStateException;
22+
use Mcp\Exception\UnknownMethodException;
2223
use Mcp\JsonRpc\MessageFactory;
2324
use Mcp\Schema\Enum\ProtocolVersion;
2425
use Mcp\Schema\JsonRpc\Error;
@@ -460,16 +461,10 @@ private function dispatch(string $method, ?array $params, string $body, RequestM
460461
{
461462
try {
462463
$request = $this->messageFactory->create($body);
464+
} catch (UnknownMethodException) {
465+
return StatelessResult::error($this->unknownMethod($method, $id), 404);
463466
} catch (InvalidInputMessageException $e) {
464-
// An unknown method (-32601) tells the client to stop asking; a malformed request (-32600) does not.
465-
$unknownMethod = \sprintf('Unknown method "%s".', $method) === $e->getMessage();
466-
467-
return StatelessResult::error(
468-
$unknownMethod
469-
? $this->unknownMethod($method, $id)
470-
: Error::forInvalidRequest($e->getMessage(), $id),
471-
$unknownMethod ? 404 : 400,
472-
);
467+
return StatelessResult::error(Error::forInvalidRequest($e->getMessage(), $id), 400);
473468
} catch (\Throwable $e) {
474469
$this->logger->warning('Rejected an unparseable modern-era request.', ['method' => $method, 'exception' => $e]);
475470

‎tests/Unit/JsonRpc/MessageFactoryTest.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Mcp\Tests\Unit\JsonRpc;
1313

1414
use Mcp\Exception\InvalidInputMessageException;
15+
use Mcp\Exception\UnknownMethodException;
1516
use Mcp\JsonRpc\MessageFactory;
1617
use Mcp\Schema\JsonRpc\Error;
1718
use Mcp\Schema\JsonRpc\Response;
@@ -280,7 +281,7 @@ public function testUnknownMethod(): void
280281
{
281282
$json = '{"jsonrpc": "2.0", "method": "unknown/method", "id": 1}';
282283

283-
$this->expectException(InvalidInputMessageException::class);
284+
$this->expectException(UnknownMethodException::class);
284285
$this->expectExceptionMessage('Unknown method');
285286

286287
$this->factory->create($json);

‎tests/Unit/Server/Stateless/StatelessProtocolTest.php‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
use Mcp\Event\RequestEvent;
1616
use Mcp\Event\ResponseEvent;
1717
use Mcp\Exception\MissingRequiredClientCapabilityException;
18+
use Mcp\JsonRpc\MessageFactory;
1819
use Mcp\Schema\ClientCapabilities;
1920
use Mcp\Schema\Content\TextResourceContents;
2021
use Mcp\Schema\Elicitation\ElicitationSchema;
2122
use Mcp\Schema\Elicitation\StringSchemaDefinition;
2223
use Mcp\Schema\Enum\CacheScope;
2324
use Mcp\Schema\Enum\LoggingLevel;
2425
use Mcp\Schema\Enum\ProtocolVersion;
26+
use Mcp\Schema\Implementation;
2527
use Mcp\Schema\JsonRpc\Error;
2628
use Mcp\Schema\JsonRpc\Request;
2729
use Mcp\Schema\JsonRpc\Response;
@@ -38,6 +40,7 @@
3840
use Mcp\Schema\ServerCapabilities;
3941
use Mcp\Server;
4042
use Mcp\Server\Authorization\AccessToken;
43+
use Mcp\Server\Configuration;
4144
use Mcp\Server\Handler\Request\RequestHandlerInterface;
4245
use Mcp\Server\RequestContext;
4346
use Mcp\Server\Session\SessionInterface;
@@ -49,6 +52,7 @@
4952
use Mcp\Server\Subscription\InMemoryNotificationBus;
5053
use Mcp\Server\Suspension\NotificationSuspension;
5154
use Mcp\Server\Wire\CachePolicy;
55+
use Mcp\Tests\Unit\Fixtures\ThrowingRequest;
5256
use Mcp\Tests\Unit\Server\Extension\ThingExtension;
5357
use Mcp\Tests\Unit\Server\Extension\UnservedThingExtension;
5458
use PHPUnit\Framework\Attributes\DataProvider;
@@ -319,6 +323,31 @@ public function testRemovedMethodsAreUnknown(string $method, array $params): voi
319323
$this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']);
320324
}
321325

326+
#[TestDox('a known method with malformed params is an invalid request, not an unknown method')]
327+
public function testMalformedRequestIsNotAnUnknownMethod(): void
328+
{
329+
$answer = self::call(self::protocol(), 'tools/list', ['cursor' => [1]]);
330+
331+
$this->assertSame(400, $answer['status']);
332+
$this->assertSame(Error::INVALID_REQUEST, $answer['body']['error']['code']);
333+
}
334+
335+
#[TestDox('a request class that crashes while hydrating is answered without leaking the failure')]
336+
public function testCrashWhileHydratingIsAnswered(): void
337+
{
338+
$protocol = new StatelessProtocol(
339+
[],
340+
new MessageFactory([ThrowingRequest::class]),
341+
new Configuration(new Implementation('test-server', '1.0.0'), new ServerCapabilities()),
342+
);
343+
344+
$answer = self::call($protocol, 'test/throwing');
345+
346+
$this->assertSame(404, $answer['status']);
347+
$this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']);
348+
$this->assertStringNotContainsString('must not leak', json_encode($answer['body'], \JSON_THROW_ON_ERROR));
349+
}
350+
322351
#[TestDox('a handshake-era client opening with "initialize" is told which revisions are served')]
323352
public function testInitializeNamesTheServedRevisions(): void
324353
{

0 commit comments

Comments
 (0)