[Schema][Server] Add the extensions framework SEP-2133 defines (#443)

* [Schema][Server] Add the extensions framework SEP-2133 defines

enableExtension() took any string and advertised it, with no way for an
extension to add a method: MessageFactory could not decode one, so nothing
downstream ever saw it.

Identifiers are now checked against the _meta key naming rules through
ExtensionIdentifier - a prefix is mandatory, since an unprefixed name has no
owner, and the modelcontextprotocol/mcp second labels the specification
reserves are recognised. An extension implementing
MethodProvidingExtensionInterface contributes both its message classes and
the handlers serving them.

MessageFactory::make() takes an $additional list of message classes, and
RequestHandlerInterface's result template is covariant so a handler
declaring a concrete result satisfies a collection typed by the interface.

* Fix stale @see reference in MethodProvidingExtensionInterface docblock

* [Schema][Server][Client] Make ExtensionIdentifier a value object

ExtensionInterface::getId() now returns ExtensionIdentifier, which
validates the SEP-2133 naming rules at construction instead of the
callers checking it. getMessages()/getRequestHandlers() move into
ExtensionInterface itself; AbstractExtension gives them empty
defaults for extensions that only announce a capability.
This commit is contained in:
Christopher Hertel 2026-08-18 20:47:27 +02:00 • committed by GitHub
commit 60711951d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 525 additions and 17 deletions

View file

@ -14,6 +14,7 @@ namespace Mcp\Client;
use Mcp\Client;
use Mcp\Client\Handler\Notification\NotificationHandlerInterface;
use Mcp\Client\Handler\Request\RequestHandlerInterface;
use Mcp\Exception\InvalidArgumentException;
use Mcp\Exception\LogicException;
use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\Enum\ProtocolVersion;
@ -88,12 +89,13 @@ final class Builder
* Enable one or more MCP protocol extensions, announced to the server under
* `capabilities.extensions` in the initialize request.
*
* @throws LogicException if the same extension is enabled more than once
* @throws InvalidArgumentException if the identifier is not a valid `_meta` prefix
* @throws LogicException if the same extension is enabled more than once
*/
public function enableExtension(ExtensionInterface ...$extensions): self
{
foreach ($extensions as $extension) {
$id = $extension->getId();
$id = (string) $extension->getId();
if (isset($this->extensions[$id])) {
throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id));

View file

@ -95,10 +95,12 @@ final class MessageFactory
/**
* Creates a new Factory instance with all the protocol's default messages.
*
* @param list<class-string<Request>|class-string<Notification>> $additional message classes an extension defines
*/
public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE): self
public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE, array $additional = []): self
{
return new self(self::REGISTERED_MESSAGES, $maxBatchSize);
return new self([...self::REGISTERED_MESSAGES, ...$additional], $maxBatchSize);
}
/**

View file

@ -0,0 +1,32 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Extension;
/**
* Base for an extension that only announces a capability and adds no RPC
* methods of its own — the common case. Implementers only need {@see
* ExtensionInterface::getId()} and {@see ExtensionInterface::getCapabilities()}.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
abstract class AbstractExtension implements ExtensionInterface
{
public function getMessages(): array
{
return [];
}
public function getRequestHandlers(): iterable
{
return [];
}
}

View file

@ -11,7 +11,8 @@
namespace Mcp\Schema\Extension\Apps;
use Mcp\Schema\Extension\ExtensionInterface;
use Mcp\Schema\Extension\AbstractExtension;
use Mcp\Schema\Extension\ExtensionIdentifier;
/**
* The MCP Apps extension (io.modelcontextprotocol/ui).
@ -26,15 +27,15 @@ use Mcp\Schema\Extension\ExtensionInterface;
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class McpApps implements ExtensionInterface
final class McpApps extends AbstractExtension
{
public const EXTENSION_ID = 'io.modelcontextprotocol/ui';
public const MIME_TYPE = 'text/html;profile=mcp-app';
public const URI_SCHEME = 'ui';
public function getId(): string
public function getId(): ExtensionIdentifier
{
return self::EXTENSION_ID;
return new ExtensionIdentifier(self::EXTENSION_ID);
}
/**

View file

@ -0,0 +1,98 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Schema\Extension;
use Mcp\Exception\InvalidArgumentException;
/**
* An extension identifier (SEP-2133): a `_meta` key with a mandatory vendor
* prefix, since an extension is something a vendor owns and an unprefixed
* name has no owner. Naming rules are enforced at construction, so any
* `ExtensionIdentifier` in hand is guaranteed well-formed.
*
* The `modelcontextprotocol`/`mcp` second label is reserved for official
* extensions, so a third party naming itself `io.modelcontextprotocol/tasks`
* would be claiming to be one — see {@see self::isReserved()}.
*
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class ExtensionIdentifier implements \Stringable
{
/** Second labels only the specification may use. */
public const RESERVED_LABELS = ['modelcontextprotocol', 'mcp'];
/** Prefixes the specification itself allocates. */
public const OFFICIAL_PREFIX = 'io.modelcontextprotocol/';
/** A label: starts with a letter, ends alphanumeric, hyphens inside. */
private const LABEL = '[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?';
/** A name: alphanumeric at both ends, `-`, `_` and `.` inside. */
private const NAME = '[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?';
/**
* @throws InvalidArgumentException if $identifier is not a valid `_meta` prefix
*/
public function __construct(
private readonly string $identifier,
) {
if (null !== $reason = self::check($identifier)) {
throw new InvalidArgumentException($reason);
}
}
/**
* Whether this identifier claims a prefix the specification reserves.
*
* Not an error on its own — the official extensions legitimately use it —
* but a third party doing so is misrepresenting itself, so callers that are
* not the SDK should refuse.
*/
public function isReserved(): bool
{
$labels = explode('.', strstr($this->identifier, '/', true) ?: '');
return \in_array($labels[1] ?? '', self::RESERVED_LABELS, true);
}
public function __toString(): string
{
return $this->identifier;
}
/**
* @return string|null the reason $identifier is invalid, or null when it is well-formed
*/
private static function check(string $identifier): ?string
{
$slash = strpos($identifier, '/');
if (false === $slash) {
return \sprintf('"%s" has no prefix; an extension identifier must be prefixed, e.g. "com.example/my-extension".', $identifier);
}
$prefix = substr($identifier, 0, $slash);
$name = substr($identifier, $slash + 1);
if (1 !== preg_match('/^'.self::LABEL.'(?:\.'.self::LABEL.')*$/', $prefix)) {
return \sprintf('"%s" is not a valid prefix: labels must start with a letter, end alphanumeric, and be separated by dots.', $prefix);
}
if ('' === $name || 1 !== preg_match('/^'.self::NAME.'$/', $name)) {
return \sprintf('"%s" is not a valid extension name: it must start and end alphanumeric.', $name);
}
return null;
}
}

View file

@ -11,6 +11,11 @@
namespace Mcp\Schema\Extension;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\ResultInterface;
use Mcp\Server\Handler\Request\RequestHandlerInterface;
/**
* An MCP protocol extension advertised during capability negotiation.
*
@ -19,6 +24,10 @@ namespace Mcp\Schema\Extension;
* extension object can be enabled on a server (initialize response) and on a client
* (initialize request); the side that enables it decides which.
*
* An extension that only announces a capability, without adding RPC methods of its
* own, can extend {@see AbstractExtension} and skip {@see self::getMessages()} and
* {@see self::getRequestHandlers()} entirely.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface ExtensionInterface
@ -26,7 +35,7 @@ interface ExtensionInterface
/**
* The reverse-DNS identifier used as the key under `capabilities.extensions`.
*/
public function getId(): string;
public function getId(): ExtensionIdentifier;
/**
* The capability payload announced for this extension.
@ -38,4 +47,24 @@ interface ExtensionInterface
* @return array<string, mixed>
*/
public function getCapabilities(): array;
/**
* Every message class this extension defines.
*
* These are registered with the {@see \Mcp\JsonRpc\MessageFactory}, without
* which an extension's method cannot be decoded off the wire at all, and
* their method names are what let a server distinguish an extension it does
* not serve from a method that does not exist. An extension with no methods
* of its own returns an empty array.
*
* @return list<class-string<Request>|class-string<Notification>>
*/
public function getMessages(): array;
/**
* The handlers serving those methods.
*
* @return iterable<RequestHandlerInterface<ResultInterface>>
*/
public function getRequestHandlers(): iterable;
}

View file

@ -14,7 +14,11 @@ namespace Mcp\Schema\JsonRpc;
use Mcp\Exception\InvalidArgumentException;
/**
* @template TResult
* Covariant because a Response only hands its result out, never consumes it,
* so a handler can declare the union of results it may answer with while each
* return path constructs one concrete type.
*
* @template-covariant TResult
*
* @phpstan-type ResponseData array{
* jsonrpc: string,

View file

@ -32,6 +32,7 @@ use Mcp\Exception\LogicException;
use Mcp\JsonRpc\MessageFactory;
use Mcp\Schema\Annotations;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Schema\Extension\AbstractExtension;
use Mcp\Schema\Extension\ExtensionInterface;
use Mcp\Schema\Icon;
use Mcp\Schema\Implementation;
@ -214,6 +215,9 @@ final class Builder
*/
private array $extensions = [];
/** @var list<class-string<\Mcp\Schema\JsonRpc\Request>|class-string<\Mcp\Schema\JsonRpc\Notification>> */
private array $extensionMessages = [];
/**
* @var LoaderInterface[]
*/
@ -280,18 +284,33 @@ final class Builder
* Enable one or more MCP protocol extensions, announced to clients under
* `capabilities.extensions` during the initialize handshake.
*
* @throws LogicException if the same extension is enabled more than once
* An extension also contributes the message classes its methods decode
* into and the handlers serving them, if any — see {@see AbstractExtension}
* 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
*/
public function enableExtension(ExtensionInterface ...$extensions): self
{
foreach ($extensions as $extension) {
$id = $extension->getId();
$id = (string) $extension->getId();
if (isset($this->extensions[$id])) {
throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id));
}
$this->extensions[$id] = $extension->getCapabilities();
// Without this the method cannot be decoded at all, so nothing
// downstream ever sees it.
foreach ($extension->getMessages() as $message) {
$this->extensionMessages[] = $message;
}
foreach ($extension->getRequestHandlers() as $handler) {
$this->requestHandlers[] = $handler;
}
}
return $this;
@ -709,7 +728,7 @@ final class Builder
$eagerlyLoaded = !$this->lazyLoading;
}
$messageFactory = MessageFactory::make();
$messageFactory = MessageFactory::make(additional: $this->extensionMessages);
$capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded);

View file

@ -23,6 +23,7 @@ use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Enum\SamplingContext;
use Mcp\Schema\Extension\ExtensionIdentifier;
use Mcp\Schema\JsonRpc\Error;
use Mcp\Schema\JsonRpc\Notification;
use Mcp\Schema\JsonRpc\Request;
@ -359,9 +360,9 @@ class ClientGateway
*
* @return bool True if the client advertised the extension, false otherwise
*/
public function supportsExtension(string $id): bool
public function supportsExtension(ExtensionIdentifier|string $id): bool
{
return $this->hasSubCapability('extensions', $id);
return $this->hasSubCapability('extensions', (string) $id);
}
/**

View file

@ -17,7 +17,11 @@ use Mcp\Schema\JsonRpc\Response;
use Mcp\Server\Session\SessionInterface;
/**
* @template TResult
* Covariant in TResult: a handler only ever produces its result, so one
* declaring a concrete result type satisfies a collection of handlers typed by
* the interface every result implements.
*
* @template-covariant TResult
*
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
*/