From 599f84dbbdf6cdf3889b405efebfde3cfb823c85 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 02:52:45 +0200 Subject: [PATCH 1/3] [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. --- CHANGELOG.md | 1 + src/JsonRpc/MessageFactory.php | 6 +- src/Schema/Extension/ExtensionIdentifier.php | 79 +++++++++++++++++ .../MethodProvidingExtensionInterface.php | 50 +++++++++++ src/Schema/JsonRpc/Response.php | 6 +- src/Server/Builder.php | 31 ++++++- .../Request/RequestHandlerInterface.php | 6 +- .../Extension/ExtensionIdentifierTest.php | 84 +++++++++++++++++++ tests/Unit/Server/BuilderTest.php | 42 ++++++++++ .../Unit/Server/Extension/ThingExtension.php | 46 ++++++++++ .../Server/Extension/ThingListHandler.php | 33 ++++++++ .../Server/Extension/ThingListRequest.php | 32 +++++++ .../Unit/Server/Extension/ThingListResult.php | 33 ++++++++ 13 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 src/Schema/Extension/ExtensionIdentifier.php create mode 100644 src/Schema/Extension/MethodProvidingExtensionInterface.php create mode 100644 tests/Unit/Schema/Extension/ExtensionIdentifierTest.php create mode 100644 tests/Unit/Server/Extension/ThingExtension.php create mode 100644 tests/Unit/Server/Extension/ThingListHandler.php create mode 100644 tests/Unit/Server/Extension/ThingListRequest.php create mode 100644 tests/Unit/Server/Extension/ThingListResult.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7f8bdf..d26d648a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. * Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response. +* Add the extensions framework SEP-2133 defines, which MCP Apps sits on. `Builder::enableExtension()` validates the identifier against the `_meta` key naming rules through the new `Mcp\Schema\Extension\ExtensionIdentifier`, and an extension implementing the new `MethodProvidingExtensionInterface` contributes both its message classes — without which its methods cannot be decoded at all — and the handlers serving them. `MessageFactory::make()` takes an `$additional` list of message classes, and `RequestHandlerInterface`'s result template is now covariant. * [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name` — the spec allows any string (its own examples use `main.rs` and `Project Files`). URI/URI-template validation is unchanged. * Add `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, and `ClientCapabilities::withExtensions()` so clients can negotiate and check protocol extensions (e.g. MCP Apps) the same way servers already do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. * Deprecate Roots, Sampling and Logging per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`). They keep working but using them now triggers a deprecation notice — migrate to tool arguments/resource URIs, a direct LLM provider API, and stderr/OpenTelemetry respectively. diff --git a/src/JsonRpc/MessageFactory.php b/src/JsonRpc/MessageFactory.php index 806eea54..27860a2c 100644 --- a/src/JsonRpc/MessageFactory.php +++ b/src/JsonRpc/MessageFactory.php @@ -95,10 +95,12 @@ public function __construct( /** * Creates a new Factory instance with all the protocol's default messages. + * + * @param list|class-string> $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); } /** diff --git a/src/Schema/Extension/ExtensionIdentifier.php b/src/Schema/Extension/ExtensionIdentifier.php new file mode 100644 index 00000000..90df1def --- /dev/null +++ b/src/Schema/Extension/ExtensionIdentifier.php @@ -0,0 +1,79 @@ + + */ +final class ExtensionIdentifier +{ + /** 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])?'; + + /** + * @return string|null the reason $identifier is invalid, or null when it is well-formed + */ + public 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; + } + + /** + * Whether $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 static function isReserved(string $identifier): bool + { + $labels = explode('.', strstr($identifier, '/', true) ?: ''); + + return \in_array($labels[1] ?? '', self::RESERVED_LABELS, true); + } +} diff --git a/src/Schema/Extension/MethodProvidingExtensionInterface.php b/src/Schema/Extension/MethodProvidingExtensionInterface.php new file mode 100644 index 00000000..4eb17b6f --- /dev/null +++ b/src/Schema/Extension/MethodProvidingExtensionInterface.php @@ -0,0 +1,50 @@ + + */ +interface MethodProvidingExtensionInterface extends ExtensionInterface +{ + /** + * 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. + * + * @return list|class-string> + */ + public function getMessages(): array; + + /** + * The handlers serving those methods. + * + * @return iterable> + */ + public function getRequestHandlers(): iterable; +} diff --git a/src/Schema/JsonRpc/Response.php b/src/Schema/JsonRpc/Response.php index 7f2d82ba..4c64881e 100644 --- a/src/Schema/JsonRpc/Response.php +++ b/src/Schema/JsonRpc/Response.php @@ -14,7 +14,11 @@ 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, diff --git a/src/Server/Builder.php b/src/Server/Builder.php index c01dd731..94f02545 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -32,7 +32,9 @@ use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Extension\ExtensionIdentifier; use Mcp\Schema\Extension\ExtensionInterface; +use Mcp\Schema\Extension\MethodProvidingExtensionInterface; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; @@ -214,6 +216,9 @@ final class Builder */ private array $extensions = []; + /** @var list|class-string<\Mcp\Schema\JsonRpc\Notification>> */ + private array $extensionMessages = []; + /** * @var LoaderInterface[] */ @@ -280,18 +285,40 @@ public function setCapabilities(ServerCapabilities $serverCapabilities): self * 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 implementing {@see MethodProvidingExtensionInterface} also + * contributes the message classes its methods decode into and the handlers + * serving them. + * + * @throws LogicException if the identifier is not a valid `_meta` prefix, or the same extension is enabled more than once */ public function enableExtension(ExtensionInterface ...$extensions): self { foreach ($extensions as $extension) { $id = $extension->getId(); + if (null !== $reason = ExtensionIdentifier::check($id)) { + throw new LogicException(\sprintf('Invalid extension identifier: %s', $reason)); + } + if (isset($this->extensions[$id])) { throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id)); } $this->extensions[$id] = $extension->getCapabilities(); + + if (!$extension instanceof MethodProvidingExtensionInterface) { + continue; + } + + // 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 +736,7 @@ public function build(): Server $eagerlyLoaded = !$this->lazyLoading; } - $messageFactory = MessageFactory::make(); + $messageFactory = MessageFactory::make(additional: $this->extensionMessages); $capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded); diff --git a/src/Server/Handler/Request/RequestHandlerInterface.php b/src/Server/Handler/Request/RequestHandlerInterface.php index d81c0795..9a9c0138 100644 --- a/src/Server/Handler/Request/RequestHandlerInterface.php +++ b/src/Server/Handler/Request/RequestHandlerInterface.php @@ -17,7 +17,11 @@ 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 */ diff --git a/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php b/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php new file mode 100644 index 00000000..a857ac08 --- /dev/null +++ b/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php @@ -0,0 +1,84 @@ + + */ + public static function validIdentifiers(): iterable + { + yield 'official tasks' => ['io.modelcontextprotocol/tasks']; + yield 'official ui' => ['io.modelcontextprotocol/ui']; + yield 'vendor' => ['com.example/my-extension']; + yield 'deep prefix' => ['org.example.api.v2/thing']; + yield 'name with dots' => ['com.example/a.b.c']; + yield 'name with underscores' => ['com.example/a_b']; + yield 'digits inside labels' => ['com.example2/x1']; + } + + #[DataProvider('validIdentifiers')] + #[TestDox('a well-formed identifier is accepted')] + public function testValidIdentifiers(string $identifier): void + { + $this->assertNull(ExtensionIdentifier::check($identifier)); + } + + /** + * @return iterable + */ + public static function invalidIdentifiers(): iterable + { + yield 'no prefix' => ['tasks', 'has no prefix']; + yield 'empty name' => ['com.example/', 'not a valid extension name']; + yield 'prefix label starting with a digit' => ['1com.example/x', 'not a valid prefix']; + yield 'prefix label ending with a hyphen' => ['com.example-/x', 'not a valid prefix']; + yield 'empty prefix label' => ['com..example/x', 'not a valid prefix']; + yield 'name starting with a dot' => ['com.example/.x', 'not a valid extension name']; + yield 'name ending with a hyphen' => ['com.example/x-', 'not a valid extension name']; + yield 'space in the name' => ['com.example/my extension', 'not a valid extension name']; + } + + #[DataProvider('invalidIdentifiers')] + #[TestDox('a malformed identifier is refused, with the reason')] + public function testInvalidIdentifiers(string $identifier, string $reason): void + { + $this->assertStringContainsString($reason, (string) ExtensionIdentifier::check($identifier)); + } + + /** + * @return iterable + */ + public static function reservations(): iterable + { + yield 'io.modelcontextprotocol is reserved' => ['io.modelcontextprotocol/tasks', true]; + yield 'dev.mcp is reserved' => ['dev.mcp/thing', true]; + yield 'org.modelcontextprotocol.api is reserved' => ['org.modelcontextprotocol.api/thing', true]; + yield 'com.mcp.tools is reserved' => ['com.mcp.tools/thing', true]; + yield 'com.example.mcp is not: the second label is example' => ['com.example.mcp/thing', false]; + yield 'com.example is not' => ['com.example/thing', false]; + yield 'a single-label prefix has no second label' => ['example/thing', false]; + } + + #[DataProvider('reservations')] + #[TestDox('the reserved second labels are recognised, and only those')] + public function testReservedPrefixes(string $identifier, bool $reserved): void + { + $this->assertSame($reserved, ExtensionIdentifier::isReserved($identifier)); + } +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index a29c9f4e..8b300ee1 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -24,9 +24,14 @@ use Mcp\Schema\ServerCapabilities; use Mcp\Schema\Tool; use Mcp\Server; +use Mcp\Server\Builder; use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Handler\Request\InitializeHandler; +use Mcp\Server\Protocol; use Mcp\Server\Session\SessionInterface; +use Mcp\Tests\Unit\Server\Extension\ThingExtension; +use Mcp\Tests\Unit\Server\Extension\ThingListHandler; +use Mcp\Tests\Unit\Server\Extension\ThingListRequest; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -172,6 +177,43 @@ public function testSetLazyLoadingReturnsSelf(): void $this->assertSame($builder, $builder->setLazyLoading(false)); } + #[TestDox('An extension identifier must be a valid _meta prefix')] + public function testEnableExtensionRejectsUnprefixedIdentifier(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Invalid extension identifier'); + + Server::builder()->enableExtension(new ThingExtension('things')); + } + + #[TestDox('A method-providing extension contributes the message classes its methods decode into')] + public function testEnableExtensionRegistersItsMessages(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->enableExtension(new ThingExtension()) + ->build(); + + $factory = (new \ReflectionProperty(Protocol::class, 'messageFactory')) + ->getValue((new \ReflectionProperty(Server::class, 'protocol'))->getValue($server)); + + $decoded = $factory->create('{"jsonrpc":"2.0","id":1,"method":"com.example/things.list"}'); + + $this->assertInstanceOf(ThingListRequest::class, $decoded[0]); + } + + #[TestDox('A method-providing extension contributes the handlers serving its methods')] + public function testEnableExtensionRegistersItsHandlers(): void + { + $builder = Server::builder() + ->setServerInfo('test', '1.0.0') + ->enableExtension(new ThingExtension()); + + $handlers = (new \ReflectionProperty(Builder::class, 'requestHandlers'))->getValue($builder); + + $this->assertContainsOnlyInstancesOf(ThingListHandler::class, $handlers); + } + #[TestDox('Lazy loading (default) advertises tools from configured sources without running loaders')] public function testLazyLoadingAdvertisesFromConfiguredSourcesWithoutLoading(): void { diff --git a/tests/Unit/Server/Extension/ThingExtension.php b/tests/Unit/Server/Extension/ThingExtension.php new file mode 100644 index 00000000..81f1d62a --- /dev/null +++ b/tests/Unit/Server/Extension/ThingExtension.php @@ -0,0 +1,46 @@ +id; + } + + public function getCapabilities(): array + { + return ['flavour' => 'vanilla']; + } + + public function getMessages(): array + { + return [ThingListRequest::class]; + } + + public function getRequestHandlers(): iterable + { + yield new ThingListHandler(); + } +} diff --git a/tests/Unit/Server/Extension/ThingListHandler.php b/tests/Unit/Server/Extension/ThingListHandler.php new file mode 100644 index 00000000..8c34b887 --- /dev/null +++ b/tests/Unit/Server/Extension/ThingListHandler.php @@ -0,0 +1,33 @@ + + */ +final class ThingListHandler implements RequestHandlerInterface +{ + public function supports(Request $request): bool + { + return $request instanceof ThingListRequest; + } + + public function handle(Request $request, SessionInterface $session): Response + { + return new Response($request->getId(), new ThingListResult(['a', 'b'])); + } +} diff --git a/tests/Unit/Server/Extension/ThingListRequest.php b/tests/Unit/Server/Extension/ThingListRequest.php new file mode 100644 index 00000000..53d5396c --- /dev/null +++ b/tests/Unit/Server/Extension/ThingListRequest.php @@ -0,0 +1,32 @@ + $things + */ + public function __construct( + public readonly array $things, + ) { + } + + /** + * @return array{things: list} + */ + public function jsonSerialize(): array + { + return ['things' => $this->things]; + } +} From 0f95fe60cba5d357e82f3c5f468aa418511620b5 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 09:22:04 +0200 Subject: [PATCH 2/3] Fix stale @see reference in MethodProvidingExtensionInterface docblock --- src/Schema/Extension/MethodProvidingExtensionInterface.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Schema/Extension/MethodProvidingExtensionInterface.php b/src/Schema/Extension/MethodProvidingExtensionInterface.php index 4eb17b6f..9cd88704 100644 --- a/src/Schema/Extension/MethodProvidingExtensionInterface.php +++ b/src/Schema/Extension/MethodProvidingExtensionInterface.php @@ -21,7 +21,7 @@ * * The two halves are declared separately because they answer different * questions: the handlers say how a claimed method is served, and - * {@see self::getMethods()} says which methods exist at all — which is what + * {@see self::getMessages()} says which methods exist at all — which is what * lets a server distinguish "this extension is not enabled here" from "no such * method", instead of answering `-32601` to both. * From 6f93891838ffe235681f6a6d563d9ee8c8b09f21 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 20:37:54 +0200 Subject: [PATCH 3/3] [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. --- CHANGELOG.md | 2 +- src/Client/Builder.php | 6 +- src/Schema/Extension/AbstractExtension.php | 32 ++++++++++ src/Schema/Extension/Apps/McpApps.php | 9 +-- src/Schema/Extension/ExtensionIdentifier.php | 61 ++++++++++++------- src/Schema/Extension/ExtensionInterface.php | 31 +++++++++- .../MethodProvidingExtensionInterface.php | 50 --------------- src/Server/Builder.php | 22 +++---- src/Server/ClientGateway.php | 5 +- .../Extension/AbstractExtensionTest.php | 39 ++++++++++++ .../Schema/Extension/Apps/McpAppsTest.php | 2 +- .../Extension/ExtensionIdentifierTest.php | 14 +++-- tests/Unit/Server/BuilderTest.php | 5 +- .../Unit/Server/Extension/ThingExtension.php | 9 +-- 14 files changed, 179 insertions(+), 108 deletions(-) create mode 100644 src/Schema/Extension/AbstractExtension.php delete mode 100644 src/Schema/Extension/MethodProvidingExtensionInterface.php create mode 100644 tests/Unit/Schema/Extension/AbstractExtensionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d26d648a..f6c45581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them. * Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response. -* Add the extensions framework SEP-2133 defines, which MCP Apps sits on. `Builder::enableExtension()` validates the identifier against the `_meta` key naming rules through the new `Mcp\Schema\Extension\ExtensionIdentifier`, and an extension implementing the new `MethodProvidingExtensionInterface` contributes both its message classes — without which its methods cannot be decoded at all — and the handlers serving them. `MessageFactory::make()` takes an `$additional` list of message classes, and `RequestHandlerInterface`'s result template is now covariant. +* [BC Break] Add the extensions framework SEP-2133 defines, which MCP Apps sits on. `ExtensionInterface::getId()` now returns the new `Mcp\Schema\Extension\ExtensionIdentifier` value object instead of a string, which validates the identifier against the `_meta` key naming rules at construction time. `ExtensionInterface` also gains `getMessages()`/`getRequestHandlers()`, so an extension can contribute the message classes its methods decode into — without which its methods cannot be decoded at all — and the handlers serving them; extensions that only announce a capability can extend the new `Mcp\Schema\Extension\AbstractExtension` and skip both. `MessageFactory::make()` takes an `$additional` list of message classes, and `RequestHandlerInterface`'s result template is now covariant. * [BC Break] Drop the SDK-only name pattern on `ResourceDefinition`/`ResourceTemplate` `$name` — the spec allows any string (its own examples use `main.rs` and `Project Files`). URI/URI-template validation is unchanged. * Add `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, and `ClientCapabilities::withExtensions()` so clients can negotiate and check protocol extensions (e.g. MCP Apps) the same way servers already do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`. * Deprecate Roots, Sampling and Logging per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`). They keep working but using them now triggers a deprecation notice — migrate to tool arguments/resource URIs, a direct LLM provider API, and stderr/OpenTelemetry respectively. diff --git a/src/Client/Builder.php b/src/Client/Builder.php index bfed70f6..098b6e6a 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -14,6 +14,7 @@ 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 @@ public function setCapabilities(ClientCapabilities $capabilities): self * 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)); diff --git a/src/Schema/Extension/AbstractExtension.php b/src/Schema/Extension/AbstractExtension.php new file mode 100644 index 00000000..a449c8a4 --- /dev/null +++ b/src/Schema/Extension/AbstractExtension.php @@ -0,0 +1,32 @@ + + */ +abstract class AbstractExtension implements ExtensionInterface +{ + public function getMessages(): array + { + return []; + } + + public function getRequestHandlers(): iterable + { + return []; + } +} diff --git a/src/Schema/Extension/Apps/McpApps.php b/src/Schema/Extension/Apps/McpApps.php index a9124a49..dcf3c9eb 100644 --- a/src/Schema/Extension/Apps/McpApps.php +++ b/src/Schema/Extension/Apps/McpApps.php @@ -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 @@ * * @author Christopher Hertel */ -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); } /** diff --git a/src/Schema/Extension/ExtensionIdentifier.php b/src/Schema/Extension/ExtensionIdentifier.php index 90df1def..2d714d6e 100644 --- a/src/Schema/Extension/ExtensionIdentifier.php +++ b/src/Schema/Extension/ExtensionIdentifier.php @@ -11,20 +11,23 @@ namespace Mcp\Schema\Extension; +use Mcp\Exception\InvalidArgumentException; + /** - * The naming rules an extension identifier has to satisfy (SEP-2133). + * 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. * - * Identifiers are `_meta` keys, with the prefix made mandatory: an extension is - * something a vendor owns, and an unprefixed name has no owner. The - * `modelcontextprotocol`/`mcp` second label is reserved for official + * 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. + * would be claiming to be one — see {@see self::isReserved()}. * * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta * * @author Christopher Hertel */ -final class ExtensionIdentifier +final class ExtensionIdentifier implements \Stringable { /** Second labels only the specification may use. */ public const RESERVED_LABELS = ['modelcontextprotocol', 'mcp']; @@ -38,10 +41,40 @@ final class ExtensionIdentifier /** 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 */ - public static function check(string $identifier): ?string + private static function check(string $identifier): ?string { $slash = strpos($identifier, '/'); @@ -62,18 +95,4 @@ public static function check(string $identifier): ?string return null; } - - /** - * Whether $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 static function isReserved(string $identifier): bool - { - $labels = explode('.', strstr($identifier, '/', true) ?: ''); - - return \in_array($labels[1] ?? '', self::RESERVED_LABELS, true); - } } diff --git a/src/Schema/Extension/ExtensionInterface.php b/src/Schema/Extension/ExtensionInterface.php index f8a4038f..a6635254 100644 --- a/src/Schema/Extension/ExtensionInterface.php +++ b/src/Schema/Extension/ExtensionInterface.php @@ -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 @@ * 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 */ 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 @@ public function getId(): string; * @return array */ 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> + */ + public function getMessages(): array; + + /** + * The handlers serving those methods. + * + * @return iterable> + */ + public function getRequestHandlers(): iterable; } diff --git a/src/Schema/Extension/MethodProvidingExtensionInterface.php b/src/Schema/Extension/MethodProvidingExtensionInterface.php deleted file mode 100644 index 9cd88704..00000000 --- a/src/Schema/Extension/MethodProvidingExtensionInterface.php +++ /dev/null @@ -1,50 +0,0 @@ - - */ -interface MethodProvidingExtensionInterface extends ExtensionInterface -{ - /** - * 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. - * - * @return list|class-string> - */ - public function getMessages(): array; - - /** - * The handlers serving those methods. - * - * @return iterable> - */ - public function getRequestHandlers(): iterable; -} diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 94f02545..431fe548 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -32,9 +32,8 @@ use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; -use Mcp\Schema\Extension\ExtensionIdentifier; +use Mcp\Schema\Extension\AbstractExtension; use Mcp\Schema\Extension\ExtensionInterface; -use Mcp\Schema\Extension\MethodProvidingExtensionInterface; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; @@ -285,20 +284,17 @@ public function setCapabilities(ServerCapabilities $serverCapabilities): self * Enable one or more MCP protocol extensions, announced to clients under * `capabilities.extensions` during the initialize handshake. * - * An extension implementing {@see MethodProvidingExtensionInterface} also - * contributes the message classes its methods decode into and the handlers - * serving them. + * 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 LogicException if the identifier is not a valid `_meta` prefix, or 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(); - - if (null !== $reason = ExtensionIdentifier::check($id)) { - throw new LogicException(\sprintf('Invalid extension identifier: %s', $reason)); - } + $id = (string) $extension->getId(); if (isset($this->extensions[$id])) { throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id)); @@ -306,10 +302,6 @@ public function enableExtension(ExtensionInterface ...$extensions): self $this->extensions[$id] = $extension->getCapabilities(); - if (!$extension instanceof MethodProvidingExtensionInterface) { - continue; - } - // Without this the method cannot be decoded at all, so nothing // downstream ever sees it. foreach ($extension->getMessages() as $message) { diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 2df6fa36..16a80f04 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -23,6 +23,7 @@ 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 @@ public function supportsElicitationUrl(): bool * * @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); } /** diff --git a/tests/Unit/Schema/Extension/AbstractExtensionTest.php b/tests/Unit/Schema/Extension/AbstractExtensionTest.php new file mode 100644 index 00000000..9ae06abd --- /dev/null +++ b/tests/Unit/Schema/Extension/AbstractExtensionTest.php @@ -0,0 +1,39 @@ +assertSame([], $extension->getMessages()); + $this->assertSame([], $extension->getRequestHandlers()); + } +} diff --git a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php index cdb8237e..6e2f60a0 100644 --- a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php +++ b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php @@ -27,7 +27,7 @@ public function testExtensionInterface(): void $extension = new McpApps(); $this->assertInstanceOf(ExtensionInterface::class, $extension); - $this->assertSame('io.modelcontextprotocol/ui', $extension->getId()); + $this->assertSame('io.modelcontextprotocol/ui', (string) $extension->getId()); $this->assertSame(['mimeTypes' => ['text/html;profile=mcp-app']], $extension->getCapabilities()); } diff --git a/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php b/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php index a857ac08..96ff22d2 100644 --- a/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php +++ b/tests/Unit/Schema/Extension/ExtensionIdentifierTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Unit\Schema\Extension; +use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Extension\ExtensionIdentifier; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; @@ -33,10 +34,10 @@ public static function validIdentifiers(): iterable } #[DataProvider('validIdentifiers')] - #[TestDox('a well-formed identifier is accepted')] + #[TestDox('a well-formed identifier is accepted and stringifies back to itself')] public function testValidIdentifiers(string $identifier): void { - $this->assertNull(ExtensionIdentifier::check($identifier)); + $this->assertSame($identifier, (string) new ExtensionIdentifier($identifier)); } /** @@ -55,10 +56,13 @@ public static function invalidIdentifiers(): iterable } #[DataProvider('invalidIdentifiers')] - #[TestDox('a malformed identifier is refused, with the reason')] + #[TestDox('a malformed identifier is refused at construction, with the reason')] public function testInvalidIdentifiers(string $identifier, string $reason): void { - $this->assertStringContainsString($reason, (string) ExtensionIdentifier::check($identifier)); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($reason); + + new ExtensionIdentifier($identifier); } /** @@ -79,6 +83,6 @@ public static function reservations(): iterable #[TestDox('the reserved second labels are recognised, and only those')] public function testReservedPrefixes(string $identifier, bool $reserved): void { - $this->assertSame($reserved, ExtensionIdentifier::isReserved($identifier)); + $this->assertSame($reserved, (new ExtensionIdentifier($identifier))->isReserved()); } } diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 8b300ee1..63bef736 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -15,6 +15,7 @@ use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandlerInterface; +use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\LogicException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Extension\Apps\McpApps; @@ -180,8 +181,8 @@ public function testSetLazyLoadingReturnsSelf(): void #[TestDox('An extension identifier must be a valid _meta prefix')] public function testEnableExtensionRejectsUnprefixedIdentifier(): void { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Invalid extension identifier'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('has no prefix'); Server::builder()->enableExtension(new ThingExtension('things')); } diff --git a/tests/Unit/Server/Extension/ThingExtension.php b/tests/Unit/Server/Extension/ThingExtension.php index 81f1d62a..88d862df 100644 --- a/tests/Unit/Server/Extension/ThingExtension.php +++ b/tests/Unit/Server/Extension/ThingExtension.php @@ -11,22 +11,23 @@ namespace Mcp\Tests\Unit\Server\Extension; -use Mcp\Schema\Extension\MethodProvidingExtensionInterface; +use Mcp\Schema\Extension\ExtensionIdentifier; +use Mcp\Schema\Extension\ExtensionInterface; /** * A minimal extension: an identifier, a capability payload, and one method it * defines and serves. */ -final class ThingExtension implements MethodProvidingExtensionInterface +final class ThingExtension implements ExtensionInterface { public function __construct( private readonly string $id = 'com.example/things', ) { } - public function getId(): string + public function getId(): ExtensionIdentifier { - return $this->id; + return new ExtensionIdentifier($this->id); } public function getCapabilities(): array