From 2bd9bc49d9de140a8216e52896adbc4c2b25d7cd Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 20:57:10 +0200 Subject: [PATCH 1/2] [Server] Serve an extension's methods under the modern lifecycle The modern dispatcher takes the method-to-extension map the builder collects, so a method belonging to an extension this server does not serve is answered -32601 naming the extension instead of a bare "no handler found". It is still an unknown method - the server genuinely does not implement it - but the caller can now act on the answer. --- src/Server/Builder.php | 7 +++ src/Server/Stateless/StatelessProtocol.php | 29 +++++++++-- .../Stateless/StatelessProtocolTest.php | 50 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 2cd24aa6..eee62617 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[] */ @@ -406,6 +409,9 @@ public function enableExtension(ExtensionInterface ...$extensions): self // downstream ever sees it. foreach ($extension->getMessages() as $message) { $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[$message::getMethod()] = $id; } foreach ($extension->getRequestHandlers() as $handler) { @@ -836,6 +842,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/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 71735ab1..eaebf377 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -34,6 +34,7 @@ use Mcp\Server\Stateless\StatelessResult; use Mcp\Server\Subscription\InMemoryNotificationBus; use Mcp\Server\Wire\CachePolicy; +use Mcp\Tests\Unit\Server\Extension\ThingExtension; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -791,6 +792,55 @@ public function testAcknowledgmentReflectsWhatTheServerCanDo(): void $this->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 does not serve says so by name')] + public function testDisabledExtensionMethodNamesItsExtension(): 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']); + // Without the extension enabled 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 notification is acknowledged with no body, never answered')] public function testNotificationIsAcknowledged(): void { From e039fbceb2e7d4875d98b6ed79565126a3234805 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 19 Aug 2026 00:58:46 +0200 Subject: [PATCH 2/2] [Server] Reject two extensions claiming the same RPC method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message factory resolves a contested method to whichever class registered first, while extensionMethods kept the last one — so error messages could name the wrong extension. Also fixes a test that claimed to prove a method gets named by its extension while asserting the opposite; the case it meant to cover (an enabled extension with no handler for one of its methods) had no coverage at all. --- src/Server/Builder.php | 14 ++++++- tests/Unit/Server/BuilderTest.php | 9 ++++ .../Extension/UnservedThingExtension.php | 42 +++++++++++++++++++ .../Stateless/StatelessProtocolTest.php | 26 ++++++++++-- 4 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/Server/Extension/UnservedThingExtension.php diff --git a/src/Server/Builder.php b/src/Server/Builder.php index eee62617..765ae19b 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -392,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 { @@ -408,10 +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[$message::getMethod()] = $id; + $this->extensionMethods[$method] = $id; } foreach ($extension->getRequestHandlers() as $handler) { 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(['flavour' => 'vanilla'], (array) $answer['body']['result']['capabilities']['extensions']['com.example/things']); } - #[TestDox('a method of an extension this server does not serve says so by name')] - public function testDisabledExtensionMethodNamesItsExtension(): void + #[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') @@ -836,11 +837,30 @@ public function testDisabledExtensionMethodNamesItsExtension(): void $this->assertSame(404, $answer['status']); $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); - // Without the extension enabled there is nothing to name it by. + // 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 {