Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* [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.
Expand Down
6 changes: 4 additions & 2 deletions src/Client/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
6 changes: 4 additions & 2 deletions src/JsonRpc/MessageFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,12 @@ public function __construct(

/**
* 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);
}

/**
Expand Down
32 changes: 32 additions & 0 deletions src/Schema/Extension/AbstractExtension.php
Original file line number Diff line number Diff line change
@@ -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 [];
}
}
9 changes: 5 additions & 4 deletions src/Schema/Extension/Apps/McpApps.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -26,15 +27,15 @@
*
* @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);
}

/**
Expand Down
98 changes: 98 additions & 0 deletions src/Schema/Extension/ExtensionIdentifier.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
31 changes: 30 additions & 1 deletion src/Schema/Extension/ExtensionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -19,14 +24,18 @@
* 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
{
/**
* 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.
Expand All @@ -38,4 +47,24 @@ public function getId(): string;
* @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;
}
6 changes: 5 additions & 1 deletion src/Schema/JsonRpc/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 22 additions & 3 deletions src/Server/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
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;
Expand Down Expand Up @@ -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[]
*/
Expand Down Expand Up @@ -280,18 +284,33 @@ 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 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;
Expand Down Expand Up @@ -709,7 +728,7 @@ public function build(): Server
$eagerlyLoaded = !$this->lazyLoading;
}

$messageFactory = MessageFactory::make();
$messageFactory = MessageFactory::make(additional: $this->extensionMessages);

$capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded);

Expand Down
5 changes: 3 additions & 2 deletions src/Server/ClientGateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down
6 changes: 5 additions & 1 deletion src/Server/Handler/Request/RequestHandlerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <koshnawaza@gmail.com>
*/
Expand Down
Loading