[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
3 changed files with 525 additions and 17 deletions
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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue