[Server] Serve an extension's methods under the modern lifecycle (#454)

* [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.

* [Server] Reject two extensions claiming the same RPC method

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.
This commit is contained in:
Christopher Hertel 2026-08-19 01:02:22 +02:00 • committed by GitHub
commit b97389325f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 165 additions and 4 deletions

View file

@ -236,6 +236,9 @@ final class Builder
/** @var list<class-string<\Mcp\Schema\JsonRpc\Request>|class-string<\Mcp\Schema\JsonRpc\Notification>> */
private array $extensionMessages = [];
/** @var array<string, string> RPC method to the extension identifier defining it */
private array $extensionMethods = [];
/**
* @var LoaderInterface[]
*/
@ -389,7 +392,8 @@ final class Builder
* 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 @@ final class Builder
// 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 @@ final class Builder
: null,
cachePolicy: $this->cachePolicy,
notificationBus: $this->notificationBus,
extensionMethods: $this->extensionMethods,
);
}

View file

@ -89,6 +89,7 @@ final class StatelessProtocol
/**
* @param iterable<RequestHandlerInterface<ResultInterface>> $requestHandlers
* @param list<ProtocolVersion> $supportedVersions
* @param array<string, string> $extensionMethods RPC method to the extension identifier defining it
*/
public function __construct(
private readonly iterable $requestHandlers,
@ -102,6 +103,7 @@ final class StatelessProtocol
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 @@ final class StatelessProtocol
} 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 @@ final class StatelessProtocol
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 @@ final class StatelessProtocol
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);
}
/**