Skip to content

Commit 6f93891

Browse files
committed
[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.
1 parent 0f95fe6 commit 6f93891

14 files changed

Lines changed: 179 additions & 108 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
77

88
* [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.
99
* 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.
10-
* 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.
10+
* [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.
1111
* [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.
1212
* 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`.
1313
* 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.

src/Client/Builder.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Mcp\Client;
1515
use Mcp\Client\Handler\Notification\NotificationHandlerInterface;
1616
use Mcp\Client\Handler\Request\RequestHandlerInterface;
17+
use Mcp\Exception\InvalidArgumentException;
1718
use Mcp\Exception\LogicException;
1819
use Mcp\Schema\ClientCapabilities;
1920
use Mcp\Schema\Enum\ProtocolVersion;
@@ -88,12 +89,13 @@ public function setCapabilities(ClientCapabilities $capabilities): self
8889
* Enable one or more MCP protocol extensions, announced to the server under
8990
* `capabilities.extensions` in the initialize request.
9091
*
91-
* @throws LogicException if the same extension is enabled more than once
92+
* @throws InvalidArgumentException if the identifier is not a valid `_meta` prefix
93+
* @throws LogicException if the same extension is enabled more than once
9294
*/
9395
public function enableExtension(ExtensionInterface ...$extensions): self
9496
{
9597
foreach ($extensions as $extension) {
96-
$id = $extension->getId();
98+
$id = (string) $extension->getId();
9799

98100
if (isset($this->extensions[$id])) {
99101
throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id));
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Schema\Extension;
13+
14+
/**
15+
* Base for an extension that only announces a capability and adds no RPC
16+
* methods of its own — the common case. Implementers only need {@see
17+
* ExtensionInterface::getId()} and {@see ExtensionInterface::getCapabilities()}.
18+
*
19+
* @author Christopher Hertel <mail@christopher-hertel.de>
20+
*/
21+
abstract class AbstractExtension implements ExtensionInterface
22+
{
23+
public function getMessages(): array
24+
{
25+
return [];
26+
}
27+
28+
public function getRequestHandlers(): iterable
29+
{
30+
return [];
31+
}
32+
}

src/Schema/Extension/Apps/McpApps.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111

1212
namespace Mcp\Schema\Extension\Apps;
1313

14-
use Mcp\Schema\Extension\ExtensionInterface;
14+
use Mcp\Schema\Extension\AbstractExtension;
15+
use Mcp\Schema\Extension\ExtensionIdentifier;
1516

1617
/**
1718
* The MCP Apps extension (io.modelcontextprotocol/ui).
@@ -26,15 +27,15 @@
2627
*
2728
* @author Christopher Hertel <mail@christopher-hertel.de>
2829
*/
29-
final class McpApps implements ExtensionInterface
30+
final class McpApps extends AbstractExtension
3031
{
3132
public const EXTENSION_ID = 'io.modelcontextprotocol/ui';
3233
public const MIME_TYPE = 'text/html;profile=mcp-app';
3334
public const URI_SCHEME = 'ui';
3435

35-
public function getId(): string
36+
public function getId(): ExtensionIdentifier
3637
{
37-
return self::EXTENSION_ID;
38+
return new ExtensionIdentifier(self::EXTENSION_ID);
3839
}
3940

4041
/**

src/Schema/Extension/ExtensionIdentifier.php

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,23 @@
1111

1212
namespace Mcp\Schema\Extension;
1313

14+
use Mcp\Exception\InvalidArgumentException;
15+
1416
/**
15-
* The naming rules an extension identifier has to satisfy (SEP-2133).
17+
* An extension identifier (SEP-2133): a `_meta` key with a mandatory vendor
18+
* prefix, since an extension is something a vendor owns and an unprefixed
19+
* name has no owner. Naming rules are enforced at construction, so any
20+
* `ExtensionIdentifier` in hand is guaranteed well-formed.
1621
*
17-
* Identifiers are `_meta` keys, with the prefix made mandatory: an extension is
18-
* something a vendor owns, and an unprefixed name has no owner. The
19-
* `modelcontextprotocol`/`mcp` second label is reserved for official
22+
* The `modelcontextprotocol`/`mcp` second label is reserved for official
2023
* extensions, so a third party naming itself `io.modelcontextprotocol/tasks`
21-
* would be claiming to be one.
24+
* would be claiming to be one — see {@see self::isReserved()}.
2225
*
2326
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta
2427
*
2528
* @author Christopher Hertel <mail@christopher-hertel.de>
2629
*/
27-
final class ExtensionIdentifier
30+
final class ExtensionIdentifier implements \Stringable
2831
{
2932
/** Second labels only the specification may use. */
3033
public const RESERVED_LABELS = ['modelcontextprotocol', 'mcp'];
@@ -38,10 +41,40 @@ final class ExtensionIdentifier
3841
/** A name: alphanumeric at both ends, `-`, `_` and `.` inside. */
3942
private const NAME = '[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?';
4043

44+
/**
45+
* @throws InvalidArgumentException if $identifier is not a valid `_meta` prefix
46+
*/
47+
public function __construct(
48+
private readonly string $identifier,
49+
) {
50+
if (null !== $reason = self::check($identifier)) {
51+
throw new InvalidArgumentException($reason);
52+
}
53+
}
54+
55+
/**
56+
* Whether this identifier claims a prefix the specification reserves.
57+
*
58+
* Not an error on its own — the official extensions legitimately use it —
59+
* but a third party doing so is misrepresenting itself, so callers that are
60+
* not the SDK should refuse.
61+
*/
62+
public function isReserved(): bool
63+
{
64+
$labels = explode('.', strstr($this->identifier, '/', true) ?: '');
65+
66+
return \in_array($labels[1] ?? '', self::RESERVED_LABELS, true);
67+
}
68+
69+
public function __toString(): string
70+
{
71+
return $this->identifier;
72+
}
73+
4174
/**
4275
* @return string|null the reason $identifier is invalid, or null when it is well-formed
4376
*/
44-
public static function check(string $identifier): ?string
77+
private static function check(string $identifier): ?string
4578
{
4679
$slash = strpos($identifier, '/');
4780

@@ -62,18 +95,4 @@ public static function check(string $identifier): ?string
6295

6396
return null;
6497
}
65-
66-
/**
67-
* Whether $identifier claims a prefix the specification reserves.
68-
*
69-
* Not an error on its own — the official extensions legitimately use it —
70-
* but a third party doing so is misrepresenting itself, so callers that are
71-
* not the SDK should refuse.
72-
*/
73-
public static function isReserved(string $identifier): bool
74-
{
75-
$labels = explode('.', strstr($identifier, '/', true) ?: '');
76-
77-
return \in_array($labels[1] ?? '', self::RESERVED_LABELS, true);
78-
}
7998
}

src/Schema/Extension/ExtensionInterface.php

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111

1212
namespace Mcp\Schema\Extension;
1313

14+
use Mcp\Schema\JsonRpc\Notification;
15+
use Mcp\Schema\JsonRpc\Request;
16+
use Mcp\Schema\JsonRpc\ResultInterface;
17+
use Mcp\Server\Handler\Request\RequestHandlerInterface;
18+
1419
/**
1520
* An MCP protocol extension advertised during capability negotiation.
1621
*
@@ -19,14 +24,18 @@
1924
* extension object can be enabled on a server (initialize response) and on a client
2025
* (initialize request); the side that enables it decides which.
2126
*
27+
* An extension that only announces a capability, without adding RPC methods of its
28+
* own, can extend {@see AbstractExtension} and skip {@see self::getMessages()} and
29+
* {@see self::getRequestHandlers()} entirely.
30+
*
2231
* @author Christopher Hertel <mail@christopher-hertel.de>
2332
*/
2433
interface ExtensionInterface
2534
{
2635
/**
2736
* The reverse-DNS identifier used as the key under `capabilities.extensions`.
2837
*/
29-
public function getId(): string;
38+
public function getId(): ExtensionIdentifier;
3039

3140
/**
3241
* The capability payload announced for this extension.
@@ -38,4 +47,24 @@ public function getId(): string;
3847
* @return array<string, mixed>
3948
*/
4049
public function getCapabilities(): array;
50+
51+
/**
52+
* Every message class this extension defines.
53+
*
54+
* These are registered with the {@see \Mcp\JsonRpc\MessageFactory}, without
55+
* which an extension's method cannot be decoded off the wire at all, and
56+
* their method names are what let a server distinguish an extension it does
57+
* not serve from a method that does not exist. An extension with no methods
58+
* of its own returns an empty array.
59+
*
60+
* @return list<class-string<Request>|class-string<Notification>>
61+
*/
62+
public function getMessages(): array;
63+
64+
/**
65+
* The handlers serving those methods.
66+
*
67+
* @return iterable<RequestHandlerInterface<ResultInterface>>
68+
*/
69+
public function getRequestHandlers(): iterable;
4170
}

src/Schema/Extension/MethodProvidingExtensionInterface.php

Lines changed: 0 additions & 50 deletions
This file was deleted.

src/Server/Builder.php

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,8 @@
3232
use Mcp\JsonRpc\MessageFactory;
3333
use Mcp\Schema\Annotations;
3434
use Mcp\Schema\Enum\ProtocolVersion;
35-
use Mcp\Schema\Extension\ExtensionIdentifier;
35+
use Mcp\Schema\Extension\AbstractExtension;
3636
use Mcp\Schema\Extension\ExtensionInterface;
37-
use Mcp\Schema\Extension\MethodProvidingExtensionInterface;
3837
use Mcp\Schema\Icon;
3938
use Mcp\Schema\Implementation;
4039
use Mcp\Schema\Prompt;
@@ -285,31 +284,24 @@ public function setCapabilities(ServerCapabilities $serverCapabilities): self
285284
* Enable one or more MCP protocol extensions, announced to clients under
286285
* `capabilities.extensions` during the initialize handshake.
287286
*
288-
* An extension implementing {@see MethodProvidingExtensionInterface} also
289-
* contributes the message classes its methods decode into and the handlers
290-
* serving them.
287+
* An extension also contributes the message classes its methods decode
288+
* into and the handlers serving them, if any — see {@see AbstractExtension}
289+
* for extensions that only announce a capability.
291290
*
292-
* @throws LogicException if the identifier is not a valid `_meta` prefix, or the same extension is enabled more than once
291+
* @throws InvalidArgumentException if the identifier is not a valid `_meta` prefix
292+
* @throws LogicException if the same extension is enabled more than once
293293
*/
294294
public function enableExtension(ExtensionInterface ...$extensions): self
295295
{
296296
foreach ($extensions as $extension) {
297-
$id = $extension->getId();
298-
299-
if (null !== $reason = ExtensionIdentifier::check($id)) {
300-
throw new LogicException(\sprintf('Invalid extension identifier: %s', $reason));
301-
}
297+
$id = (string) $extension->getId();
302298

303299
if (isset($this->extensions[$id])) {
304300
throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id));
305301
}
306302

307303
$this->extensions[$id] = $extension->getCapabilities();
308304

309-
if (!$extension instanceof MethodProvidingExtensionInterface) {
310-
continue;
311-
}
312-
313305
// Without this the method cannot be decoded at all, so nothing
314306
// downstream ever sees it.
315307
foreach ($extension->getMessages() as $message) {

src/Server/ClientGateway.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Mcp\Schema\Enum\LoggingLevel;
2424
use Mcp\Schema\Enum\Role;
2525
use Mcp\Schema\Enum\SamplingContext;
26+
use Mcp\Schema\Extension\ExtensionIdentifier;
2627
use Mcp\Schema\JsonRpc\Error;
2728
use Mcp\Schema\JsonRpc\Notification;
2829
use Mcp\Schema\JsonRpc\Request;
@@ -359,9 +360,9 @@ public function supportsElicitationUrl(): bool
359360
*
360361
* @return bool True if the client advertised the extension, false otherwise
361362
*/
362-
public function supportsExtension(string $id): bool
363+
public function supportsExtension(ExtensionIdentifier|string $id): bool
363364
{
364-
return $this->hasSubCapability('extensions', $id);
365+
return $this->hasSubCapability('extensions', (string) $id);
365366
}
366367

367368
/**

0 commit comments

Comments
 (0)