[Schema][Client][Server] Close MCP Apps gaps: extension negotiation checks (#429)
* [Schema][Client][Server] Close MCP Apps gaps: extension negotiation checks Add ClientGateway::supportsExtension(), Client\Builder::enableExtension() and ClientCapabilities::withExtensions(); introduce side-agnostic ExtensionInterface and deprecate ServerExtensionInterface; document attribute-based UI linkage. * Drop ServerExtensionInterface in favour of ExtensionInterface
This commit is contained in:
parent
590afb575b
commit
6f58fe596a
6 changed files with 273 additions and 21 deletions
|
|
@ -15,6 +15,7 @@ use Mcp\Capability\Completion\EnumCompletionProvider;
|
|||
use Mcp\Capability\Completion\ListCompletionProvider;
|
||||
use Mcp\Capability\Discovery\Discoverer;
|
||||
use Mcp\Capability\Registry\ToolReference;
|
||||
use Mcp\Schema\Extension\Apps\McpApps;
|
||||
use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture;
|
||||
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\AlternativeFileNameToolHandler;
|
||||
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler;
|
||||
|
|
@ -38,7 +39,7 @@ class DiscoveryTest extends TestCase
|
|||
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']);
|
||||
|
||||
$tools = $discovery->getTools();
|
||||
$this->assertCount(5, $tools);
|
||||
$this->assertCount(6, $tools);
|
||||
|
||||
$this->assertArrayHasKey('greet_user', $tools);
|
||||
$this->assertEquals('greet_user', $tools['greet_user']->tool->name);
|
||||
|
|
@ -63,7 +64,7 @@ class DiscoveryTest extends TestCase
|
|||
$this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools);
|
||||
|
||||
$resources = $discovery->getResources();
|
||||
$this->assertCount(3, $resources);
|
||||
$this->assertCount(4, $resources);
|
||||
|
||||
$this->assertArrayHasKey('app://info/version', $resources);
|
||||
$this->assertEquals('app_version', $resources['app://info/version']->resource->name);
|
||||
|
|
@ -72,6 +73,14 @@ class DiscoveryTest extends TestCase
|
|||
$this->assertArrayHasKey('invokable://config/status', $resources);
|
||||
$this->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler);
|
||||
|
||||
$this->assertArrayHasKey('ui://widget/clock', $resources);
|
||||
$this->assertEquals(McpApps::MIME_TYPE, $resources['ui://widget/clock']->resource->mimeType);
|
||||
$this->assertJsonStringEqualsJsonString('{"ui":{}}', json_encode($resources['ui://widget/clock']->resource->meta));
|
||||
$this->assertJsonStringEqualsJsonString(
|
||||
'{"ui":{"resourceUri":"ui://widget/clock","visibility":["app"]}}',
|
||||
json_encode($tools['show_clock']->tool->meta),
|
||||
);
|
||||
|
||||
$prompts = $discovery->getPrompts();
|
||||
$this->assertCount(4, $prompts);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
<?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\Capability\Discovery\Fixtures;
|
||||
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Schema\Content\TextResourceContents;
|
||||
use Mcp\Schema\Extension\Apps\McpApps;
|
||||
use Mcp\Schema\Extension\Apps\ToolVisibility;
|
||||
use Mcp\Schema\Extension\Apps\UiResourceContentMeta;
|
||||
use Mcp\Schema\Extension\Apps\UiResourceCsp;
|
||||
use Mcp\Schema\Extension\Apps\UiToolMeta;
|
||||
|
||||
/**
|
||||
* An MCP App declared purely through attributes: a `ui://` resource flagged with the
|
||||
* `_meta.ui` marker and a tool linked to it via `UiToolMeta`.
|
||||
*/
|
||||
class DiscoverableUiHandler
|
||||
{
|
||||
#[McpResource(
|
||||
uri: 'ui://widget/clock',
|
||||
name: 'clock_widget',
|
||||
mimeType: McpApps::MIME_TYPE,
|
||||
meta: ['ui' => new \stdClass()],
|
||||
)]
|
||||
public function widget(): TextResourceContents
|
||||
{
|
||||
return new TextResourceContents(
|
||||
uri: 'ui://widget/clock',
|
||||
mimeType: McpApps::MIME_TYPE,
|
||||
text: '<html></html>',
|
||||
meta: ['ui' => new UiResourceContentMeta(csp: new UiResourceCsp(connectDomains: ['https://time.example.com']))],
|
||||
);
|
||||
}
|
||||
|
||||
#[McpTool(
|
||||
name: 'show_clock',
|
||||
meta: ['ui' => new UiToolMeta(resourceUri: 'ui://widget/clock', visibility: [ToolVisibility::App])],
|
||||
)]
|
||||
public function showClock(string $timezone): string
|
||||
{
|
||||
return $timezone;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ namespace Mcp\Tests\Unit\Client;
|
|||
|
||||
use Mcp\Client;
|
||||
use Mcp\Client\Configuration;
|
||||
use Mcp\Exception\LogicException;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Extension\Apps\McpApps;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
|
|
@ -43,6 +46,41 @@ final class BuilderTest extends TestCase
|
|||
$this->assertNull($this->extractConfiguration($client)->clientInfo->title);
|
||||
}
|
||||
|
||||
#[TestDox('enableExtension() announces the extension under capabilities.extensions')]
|
||||
public function testEnableExtensionAdvertisesExtension(): void
|
||||
{
|
||||
$client = Client::builder()
|
||||
->enableExtension(new McpApps())
|
||||
->build();
|
||||
|
||||
$capabilities = $this->extractConfiguration($client)->capabilities;
|
||||
|
||||
$this->assertSame([McpApps::EXTENSION_ID => ['mimeTypes' => [McpApps::MIME_TYPE]]], $capabilities->extensions);
|
||||
}
|
||||
|
||||
#[TestDox('enableExtension() extensions are merged into capabilities set via setCapabilities()')]
|
||||
public function testEnableExtensionMergesIntoCustomCapabilities(): void
|
||||
{
|
||||
$client = Client::builder()
|
||||
->setCapabilities(new ClientCapabilities(elicitation: true, extensions: ['io.example/other' => []]))
|
||||
->enableExtension(new McpApps())
|
||||
->build();
|
||||
|
||||
$capabilities = $this->extractConfiguration($client)->capabilities;
|
||||
|
||||
$this->assertTrue($capabilities->elicitation);
|
||||
$this->assertSame(['io.example/other', McpApps::EXTENSION_ID], array_keys($capabilities->extensions ?? []));
|
||||
}
|
||||
|
||||
#[TestDox('enableExtension() throws when the same extension is enabled twice')]
|
||||
public function testEnableExtensionRejectsDuplicate(): void
|
||||
{
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage(McpApps::EXTENSION_ID);
|
||||
|
||||
Client::builder()->enableExtension(new McpApps(), new McpApps());
|
||||
}
|
||||
|
||||
private function extractConfiguration(Client $client): Configuration
|
||||
{
|
||||
$config = (new \ReflectionClass($client))->getProperty('config')->getValue($client);
|
||||
|
|
|
|||
|
|
@ -17,14 +17,16 @@ use Mcp\Schema\Extension\Apps\UiResourceContentMeta;
|
|||
use Mcp\Schema\Extension\Apps\UiResourceCsp;
|
||||
use Mcp\Schema\Extension\Apps\UiResourcePermissions;
|
||||
use Mcp\Schema\Extension\Apps\UiToolMeta;
|
||||
use Mcp\Schema\Extension\ExtensionInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class McpAppsTest extends TestCase
|
||||
{
|
||||
public function testServerExtensionInterface(): void
|
||||
public function testExtensionInterface(): void
|
||||
{
|
||||
$extension = new McpApps();
|
||||
|
||||
$this->assertInstanceOf(ExtensionInterface::class, $extension);
|
||||
$this->assertSame('io.modelcontextprotocol/ui', $extension->getId());
|
||||
$this->assertSame(['mimeTypes' => ['text/html;profile=mcp-app']], $extension->getCapabilities());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,17 @@ class CapabilitiesExtensionsTest extends TestCase
|
|||
$this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 2]], $caps->extensions, 'original is unchanged');
|
||||
}
|
||||
|
||||
public function testClientCapabilitiesWithExtensionsMerges(): void
|
||||
{
|
||||
$caps = new ClientCapabilities(elicitation: true, extensions: ['a' => ['x' => 1], 'b' => ['y' => 2]]);
|
||||
|
||||
$merged = $caps->withExtensions(['b' => ['y' => 99], 'c' => ['z' => 3]]);
|
||||
|
||||
$this->assertTrue($merged->elicitation);
|
||||
$this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 99], 'c' => ['z' => 3]], $merged->extensions);
|
||||
$this->assertSame(['a' => ['x' => 1], 'b' => ['y' => 2]], $caps->extensions, 'original is unchanged');
|
||||
}
|
||||
|
||||
public function testClientCapabilitiesWithExtensions(): void
|
||||
{
|
||||
$extensions = [
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use Mcp\Schema\Elicitation\ElicitationSchema;
|
|||
use Mcp\Schema\Elicitation\StringSchemaDefinition;
|
||||
use Mcp\Schema\Enum\ElicitAction;
|
||||
use Mcp\Schema\Enum\ElicitationMode;
|
||||
use Mcp\Schema\Extension\Apps\McpApps;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
|
|
@ -86,6 +87,17 @@ final class ClientGatewayTest extends TestCase
|
|||
$this->assertTrue($this->gatewayFor((array) $capabilities)->supportsSamplingTools());
|
||||
}
|
||||
|
||||
public function testSupportsExtensionReflectsTheNegotiatedExtensions(): void
|
||||
{
|
||||
$this->assertTrue($this->gatewayFor(['extensions' => [McpApps::EXTENSION_ID => ['mimeTypes' => [McpApps::MIME_TYPE]]]])->supportsExtension(McpApps::EXTENSION_ID));
|
||||
$this->assertFalse($this->gatewayFor(['extensions' => ['io.example/other' => []]])->supportsExtension(McpApps::EXTENSION_ID));
|
||||
$this->assertFalse($this->gatewayFor([])->supportsExtension(McpApps::EXTENSION_ID));
|
||||
|
||||
// Live sessions hold the serialized object shape.
|
||||
$capabilities = (new ClientCapabilities(extensions: [McpApps::EXTENSION_ID => (new McpApps())->getCapabilities()]))->jsonSerialize();
|
||||
$this->assertTrue($this->gatewayFor((array) $capabilities)->supportsExtension(McpApps::EXTENSION_ID));
|
||||
}
|
||||
|
||||
public function testSupportsElicitationUrlReflectsTheSubCapability(): void
|
||||
{
|
||||
$this->assertTrue($this->gatewayFor(['elicitation' => ['url' => []]])->supportsElicitationUrl());
|
||||
|
|
|
|||
Loading…
Reference in a new issue