* [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.
88 lines
3.6 KiB
PHP
88 lines
3.6 KiB
PHP
<?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());
|
|
}
|
|
}
|