[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:
parent
5e3e69acc4
commit
60711951d4
19 changed files with 525 additions and 17 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
32
src/Schema/Extension/AbstractExtension.php
Normal file
32
src/Schema/Extension/AbstractExtension.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
98
src/Schema/Extension/ExtensionIdentifier.php
Normal file
98
src/Schema/Extension/ExtensionIdentifier.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
*/
|
||||
|
|
|
|||
39
tests/Unit/Schema/Extension/AbstractExtensionTest.php
Normal file
39
tests/Unit/Schema/Extension/AbstractExtensionTest.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?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\Tests\Unit\Schema\Extension;
|
||||
|
||||
use Mcp\Schema\Extension\AbstractExtension;
|
||||
use Mcp\Schema\Extension\ExtensionIdentifier;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AbstractExtensionTest extends TestCase
|
||||
{
|
||||
#[TestDox('An extension that only announces a capability need not declare messages or handlers')]
|
||||
public function testDefaultsToNoMessagesOrHandlers(): void
|
||||
{
|
||||
$extension = new class extends AbstractExtension {
|
||||
public function getId(): ExtensionIdentifier
|
||||
{
|
||||
return new ExtensionIdentifier('com.example/minimal');
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame([], $extension->getMessages());
|
||||
$this->assertSame([], $extension->getRequestHandlers());
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ class McpAppsTest extends TestCase
|
|||
$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());
|
||||
}
|
||||
|
||||
|
|
|
|||
88
tests/Unit/Schema/Extension/ExtensionIdentifierTest.php
Normal file
88
tests/Unit/Schema/Extension/ExtensionIdentifierTest.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?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\Tests\Unit\Schema\Extension;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Schema\Extension\ExtensionIdentifier;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExtensionIdentifierTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
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 and stringifies back to itself')]
|
||||
public function testValidIdentifiers(string $identifier): void
|
||||
{
|
||||
$this->assertSame($identifier, (string) new ExtensionIdentifier($identifier));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, string}>
|
||||
*/
|
||||
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 at construction, with the reason')]
|
||||
public function testInvalidIdentifiers(string $identifier, string $reason): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage($reason);
|
||||
|
||||
new ExtensionIdentifier($identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, bool}>
|
||||
*/
|
||||
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, (new ExtensionIdentifier($identifier))->isReserved());
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ use Mcp\Capability\Registry;
|
|||
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;
|
||||
|
|
@ -24,9 +25,14 @@ use Mcp\Schema\Request\CallToolRequest;
|
|||
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 +178,43 @@ final class BuilderTest extends TestCase
|
|||
$this->assertSame($builder, $builder->setLazyLoading(false));
|
||||
}
|
||||
|
||||
#[TestDox('An extension identifier must be a valid _meta prefix')]
|
||||
public function testEnableExtensionRejectsUnprefixedIdentifier(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('has no prefix');
|
||||
|
||||
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
|
||||
{
|
||||
|
|
|
|||
47
tests/Unit/Server/Extension/ThingExtension.php
Normal file
47
tests/Unit/Server/Extension/ThingExtension.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?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\Tests\Unit\Server\Extension;
|
||||
|
||||
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 ExtensionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $id = 'com.example/things',
|
||||
) {
|
||||
}
|
||||
|
||||
public function getId(): ExtensionIdentifier
|
||||
{
|
||||
return new ExtensionIdentifier($this->id);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
{
|
||||
return ['flavour' => 'vanilla'];
|
||||
}
|
||||
|
||||
public function getMessages(): array
|
||||
{
|
||||
return [ThingListRequest::class];
|
||||
}
|
||||
|
||||
public function getRequestHandlers(): iterable
|
||||
{
|
||||
yield new ThingListHandler();
|
||||
}
|
||||
}
|
||||
33
tests/Unit/Server/Extension/ThingListHandler.php
Normal file
33
tests/Unit/Server/Extension/ThingListHandler.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?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\Tests\Unit\Server\Extension;
|
||||
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Server\Session\SessionInterface;
|
||||
|
||||
/**
|
||||
* @implements RequestHandlerInterface<ThingListResult>
|
||||
*/
|
||||
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']));
|
||||
}
|
||||
}
|
||||
32
tests/Unit/Server/Extension/ThingListRequest.php
Normal file
32
tests/Unit/Server/Extension/ThingListRequest.php
Normal 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\Tests\Unit\Server\Extension;
|
||||
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
|
||||
final class ThingListRequest extends Request
|
||||
{
|
||||
public static function getMethod(): string
|
||||
{
|
||||
return 'com.example/things.list';
|
||||
}
|
||||
|
||||
protected static function fromParams(?array $params): static
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
protected function getParams(): ?array
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
33
tests/Unit/Server/Extension/ThingListResult.php
Normal file
33
tests/Unit/Server/Extension/ThingListResult.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?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\Tests\Unit\Server\Extension;
|
||||
|
||||
use Mcp\Schema\JsonRpc\ResultInterface;
|
||||
|
||||
final class ThingListResult implements ResultInterface
|
||||
{
|
||||
/**
|
||||
* @param list<string> $things
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $things,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{things: list<string>}
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return ['things' => $this->things];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue