diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 2cd24aa6..765ae19b 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -236,6 +236,9 @@ final class Builder /** @var list|class-string<\Mcp\Schema\JsonRpc\Notification>> */ private array $extensionMessages = []; + /** @var array RPC method to the extension identifier defining it */ + private array $extensionMethods = []; + /** * @var LoaderInterface[] */ @@ -389,7 +392,8 @@ public function setCapabilities(ServerCapabilities $serverCapabilities): self * for extensions that only announce a capability. * * @throws InvalidArgumentException if the identifier is not a valid `_meta` prefix - * @throws LogicException if the same extension is enabled more than once + * @throws LogicException if the same extension is enabled more than once, or + * two enabled extensions define the same RPC method */ public function enableExtension(ExtensionInterface ...$extensions): self { @@ -405,7 +409,19 @@ public function enableExtension(ExtensionInterface ...$extensions): self // Without this the method cannot be decoded at all, so nothing // downstream ever sees it. foreach ($extension->getMessages() as $message) { + $method = $message::getMethod(); + + // The message factory resolves a method to whichever class was + // registered first, so a second owner here would silently lose + // the dispatch race while still being named in error messages. + if (isset($this->extensionMethods[$method]) && $this->extensionMethods[$method] !== $id) { + throw new LogicException(\sprintf('Method "%s" is already claimed by extension "%s", so extension "%s" cannot also define it.', $method, $this->extensionMethods[$method], $id)); + } + $this->extensionMessages[] = $message; + // Recorded even though the handler answers it, so a server with + // the extension *off* can say so instead of "no such method". + $this->extensionMethods[$method] = $id; } foreach ($extension->getRequestHandlers() as $handler) { @@ -836,6 +852,7 @@ public function buildStateless(array $supportedVersions = [ProtocolVersion::V202 : null, cachePolicy: $this->cachePolicy, notificationBus: $this->notificationBus, + extensionMethods: $this->extensionMethods, ); } diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index ba1721eb..39cc0237 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -89,6 +89,7 @@ final class StatelessProtocol /** * @param iterable> $requestHandlers * @param list $supportedVersions + * @param array $extensionMethods RPC method to the extension identifier defining it */ public function __construct( private readonly iterable $requestHandlers, @@ -102,6 +103,7 @@ public function __construct( private readonly ?RequestStateCodec $requestStateCodec = null, ?CachePolicy $cachePolicy = null, private readonly ?NotificationBusInterface $notificationBus = null, + private readonly array $extensionMethods = [], ) { $this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo, $cachePolicy); @@ -396,7 +398,7 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str } catch (\Throwable $e) { $this->logger->warning('Rejected an unparseable modern-era request.', ['method' => $method, 'exception' => $e]); - return StatelessResult::error(Error::forMethodNotFound(\sprintf('Method "%s" is not supported.', $method), $id), 404); + return StatelessResult::error($this->unknownMethod($method, $id), 404); } $request = $messages[0] ?? null; @@ -410,7 +412,7 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str return StatelessResult::error( $unknownMethod - ? Error::forMethodNotFound($request->getMessage(), $id) + ? $this->unknownMethod($method, $id) : Error::forInvalidRequest($request->getMessage(), $id), $unknownMethod ? 404 : 400, ); @@ -510,7 +512,28 @@ private function dispatch(string $method, array $decoded, RequestMeta $meta, str return $this->encode($method, $id, $result->result, null === $input); } - return StatelessResult::error(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404); + return StatelessResult::error($this->unknownMethod($method, $id), 404); + } + + /** + * A method with no handler, said as precisely as the server can. + * + * An extension's method is still `-32601` when the extension is off — the + * server genuinely does not implement it — but naming the extension turns + * an opaque refusal into something the caller can act on. + */ + private function unknownMethod(string $method, string|int $id): Error + { + $extension = $this->extensionMethods[$method] ?? null; + + if (null !== $extension) { + return Error::forMethodNotFound( + \sprintf('Method "%s" belongs to the "%s" extension, which this server does not serve.', $method, $extension), + $id, + ); + } + + return Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id); } /** diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 63bef736..5ddb551f 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -203,6 +203,15 @@ public function testEnableExtensionRegistersItsMessages(): void $this->assertInstanceOf(ThingListRequest::class, $decoded[0]); } + #[TestDox('enableExtension() throws when two enabled extensions define the same RPC method')] + public function testEnableExtensionRejectsClaimedMethod(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('com.example/things.list'); + + Server::builder()->enableExtension(new ThingExtension('com.example/things-a'), new ThingExtension('com.example/things-b')); + } + #[TestDox('A method-providing extension contributes the handlers serving its methods')] public function testEnableExtensionRegistersItsHandlers(): void { diff --git a/tests/Unit/Server/Extension/UnservedThingExtension.php b/tests/Unit/Server/Extension/UnservedThingExtension.php new file mode 100644 index 00000000..43a92076 --- /dev/null +++ b/tests/Unit/Server/Extension/UnservedThingExtension.php @@ -0,0 +1,42 @@ +assertSame(['toolsListChanged' => true], (array) $first['params']['notifications']); } + #[TestDox('an extension method is served by the extension that claims it')] + public function testExtensionMethodIsServed(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->enableExtension(new ThingExtension()) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'com.example/things.list', + ]); + + $this->assertSame(200, $answer['status']); + $this->assertSame(['a', 'b'], $answer['body']['result']['things']); + } + + #[TestDox('the extension is advertised under capabilities.extensions')] + public function testExtensionIsAdvertised(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->enableExtension(new ThingExtension()) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::call($protocol, 'server/discover'); + + $this->assertSame(['flavour' => 'vanilla'], (array) $answer['body']['result']['capabilities']['extensions']['com.example/things']); + } + + #[TestDox('a method of an extension this server has never heard of stays generic')] + public function testUnknownExtensionMethodStaysGeneric(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'com.example/things.list', + ]); + + $this->assertSame(404, $answer['status']); + $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); + // The extension was never enabled, so it never entered the method map + // — there is nothing to name it by. + $this->assertStringContainsString('com.example/things.list', $answer['body']['error']['message']); + $this->assertStringNotContainsString('extension', $answer['body']['error']['message']); + } + + #[TestDox('a method of an extension this server does not serve says so by name')] + public function testUnservedExtensionMethodNamesItsExtension(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->enableExtension(new UnservedThingExtension()) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => 'com.example/things.list', + ]); + + $this->assertSame(404, $answer['status']); + $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); + $this->assertStringContainsString('com.example/unserved-things', $answer['body']['error']['message']); + } + #[TestDox('a notification is acknowledged with no body, never answered')] public function testNotificationIsAcknowledged(): void {