[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
15 changed files with 273 additions and 21 deletions
|
|
@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
|||
0.8.0
|
||||
-----
|
||||
|
||||
* Add `ClientGateway::supportsExtension()` to check whether the client negotiated a protocol extension (e.g. `McpApps::EXTENSION_ID`) before offering UI-linked tools, plus `Client\Builder::enableExtension()` and `ClientCapabilities::withExtensions()` so hosts advertise extensions the same way servers do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`.
|
||||
* Deprecate the Roots, Sampling and Logging features per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`): the schema, client and server classes that make them up are marked `@deprecated` with the suggested migration (tool arguments or resource URIs instead of roots; an LLM provider's API instead of sampling; stderr or OpenTelemetry instead of logging), and exercising them — `ClientGateway::log()` / `sample()` / `listRoots()`, or registering a client-side `SamplingRequestHandler`, `ListRootsRequestHandler` or `LoggingNotificationHandler` — triggers a silenced `E_USER_DEPRECATED` via `symfony/deprecation-contracts`. Everything remains fully functional until removal.
|
||||
* Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`.
|
||||
* Prompt generators returning content as typed arrays (`['type' => 'text', ...]` etc.) no longer lose the optional fields: `annotations` on every content type, and `_meta` and an explicit `mimeType` on embedded resource contents, now carry through to the resulting `PromptMessage` instead of being silently dropped. A missing resource `mimeType` still defaults to `text/plain`/`application/octet-stream` as before.
|
||||
|
|
|
|||
|
|
@ -13,14 +13,37 @@ $server = Server::builder()
|
|||
->build();
|
||||
```
|
||||
|
||||
Pass one or more `ServerExtensionInterface` instances; multiple extensions can
|
||||
Pass one or more `ExtensionInterface` instances; multiple extensions can
|
||||
be enabled in a single call. Enabling the same extension twice throws a
|
||||
`LogicException`.
|
||||
|
||||
Clients (hosts) advertise the extensions they support the same way, via
|
||||
`Client\Builder::enableExtension()`; the payload lands under
|
||||
`capabilities.extensions` in the initialize request.
|
||||
|
||||
> Note: extensions enabled via `enableExtension()` are merged into the
|
||||
> `extensions` capability even when you supply your own `ServerCapabilities` via
|
||||
> `setCapabilities()`. An enabled extension overrides any entry under the same
|
||||
> id already present in those capabilities.
|
||||
> `extensions` capability even when you supply your own `ServerCapabilities` /
|
||||
> `ClientCapabilities` via `setCapabilities()`. An enabled extension overrides
|
||||
> any entry under the same id already present in those capabilities.
|
||||
|
||||
## Checking what the client negotiated
|
||||
|
||||
An extension is only in effect when both sides advertise it. On the server, a
|
||||
handler can ask the `ClientGateway` from the injected `RequestContext` whether the
|
||||
connected client did:
|
||||
|
||||
```php
|
||||
use Mcp\Schema\Extension\Apps\McpApps;
|
||||
use Mcp\Server\RequestContext;
|
||||
|
||||
public function getWeather(string $city, RequestContext $context): string
|
||||
{
|
||||
if (!$context->getClientGateway()->supportsExtension(McpApps::EXTENSION_ID)) {
|
||||
// text-only fallback for hosts without MCP Apps support
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## MCP Apps (`io.modelcontextprotocol/ui`)
|
||||
|
||||
|
|
@ -78,6 +101,30 @@ flagging it as an MCP App, while the resource *content* returned by `resources/r
|
|||
carries the structured `UiResourceContentMeta` with the actual CSP and permission
|
||||
configuration.
|
||||
|
||||
### Attribute-based discovery
|
||||
|
||||
The same linkage works with `#[McpResource]` / `#[McpTool]`, since both accept a
|
||||
`meta` array and PHP allows `new` in attribute arguments. The one difference is the
|
||||
descriptor marker: `McpApps::resourceMarker()` is a method call and cannot appear
|
||||
in an attribute, so spell it as `new \stdClass()` there.
|
||||
|
||||
```php
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Schema\Extension\Apps\McpApps;
|
||||
use Mcp\Schema\Extension\Apps\ToolVisibility;
|
||||
use Mcp\Schema\Extension\Apps\UiToolMeta;
|
||||
|
||||
final class WeatherApp
|
||||
{
|
||||
#[McpResource(uri: 'ui://my-app', mimeType: McpApps::MIME_TYPE, meta: ['ui' => new \stdClass()])]
|
||||
public function view(): TextResourceContents { /* as above */ }
|
||||
|
||||
#[McpTool(name: 'my_tool', meta: ['ui' => new UiToolMeta(resourceUri: 'ui://my-app', visibility: [ToolVisibility::Model, ToolVisibility::App])])]
|
||||
public function myTool(string $city): string { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Server-side DTOs
|
||||
|
||||
| Class | Purpose |
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use Mcp\Schema\Extension\Apps\McpApps;
|
|||
use Mcp\Schema\Extension\Apps\UiResourceContentMeta;
|
||||
use Mcp\Schema\Extension\Apps\UiResourceCsp;
|
||||
use Mcp\Schema\Extension\Apps\UiResourcePermissions;
|
||||
use Mcp\Server\RequestContext;
|
||||
|
||||
final class WeatherApp
|
||||
{
|
||||
|
|
@ -39,7 +40,7 @@ final class WeatherApp
|
|||
);
|
||||
}
|
||||
|
||||
public function getWeather(string $city): string
|
||||
public function getWeather(string $city, RequestContext $context): string
|
||||
{
|
||||
$weather = [
|
||||
'london' => ['temp' => '15°C', 'condition' => 'Cloudy', 'humidity' => '78%'],
|
||||
|
|
@ -56,12 +57,20 @@ final class WeatherApp
|
|||
$key = strtolower($city);
|
||||
$data = $weather[$key] ?? ['temp' => '20°C', 'condition' => 'Clear', 'humidity' => '60%'];
|
||||
|
||||
return \sprintf(
|
||||
$text = \sprintf(
|
||||
'Weather in %s: %s, %s, Humidity: %s',
|
||||
$city,
|
||||
$data['temp'],
|
||||
$data['condition'],
|
||||
$data['humidity'],
|
||||
);
|
||||
|
||||
// Hosts without MCP Apps support only ever see this text; the ones that
|
||||
// negotiated the extension render the ui://weather-app view on top of it.
|
||||
if (!$context->getClientGateway()->supportsExtension(McpApps::EXTENSION_ID)) {
|
||||
return $text.' (text-only, host does not support MCP Apps)';
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ namespace Mcp\Client;
|
|||
use Mcp\Client;
|
||||
use Mcp\Client\Handler\Notification\NotificationHandlerInterface;
|
||||
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Exception\LogicException;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\Extension\ExtensionInterface;
|
||||
use Mcp\Schema\Implementation;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
|
@ -33,6 +35,9 @@ final class Builder
|
|||
private ?string $title = null;
|
||||
private ?ProtocolVersion $protocolVersion = null;
|
||||
private ?ClientCapabilities $capabilities = null;
|
||||
|
||||
/** @var array<string, array<string, mixed>> */
|
||||
private array $extensions = [];
|
||||
private int $initTimeout = 30;
|
||||
private int $requestTimeout = 120;
|
||||
private int $maxRetries = 3;
|
||||
|
|
@ -79,6 +84,27 @@ final class Builder
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public function enableExtension(ExtensionInterface ...$extensions): self
|
||||
{
|
||||
foreach ($extensions as $extension) {
|
||||
$id = $extension->getId();
|
||||
|
||||
if (isset($this->extensions[$id])) {
|
||||
throw new LogicException(\sprintf('Extension "%s" is already enabled.', $id));
|
||||
}
|
||||
|
||||
$this->extensions[$id] = $extension->getCapabilities();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set initialization timeout in seconds.
|
||||
*/
|
||||
|
|
@ -159,9 +185,17 @@ final class Builder
|
|||
title: $this->title,
|
||||
);
|
||||
|
||||
$capabilities = $this->capabilities ?? new ClientCapabilities();
|
||||
|
||||
// Extensions enabled via enableExtension() are folded into caller-supplied
|
||||
// capabilities too, so setCapabilities() does not silently drop them.
|
||||
if ([] !== $this->extensions) {
|
||||
$capabilities = $capabilities->withExtensions($this->extensions);
|
||||
}
|
||||
|
||||
$config = new Configuration(
|
||||
clientInfo: $clientInfo,
|
||||
capabilities: $this->capabilities ?? new ClientCapabilities(),
|
||||
capabilities: $capabilities,
|
||||
protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_11_25,
|
||||
initTimeout: $this->initTimeout,
|
||||
requestTimeout: $this->requestTimeout,
|
||||
|
|
|
|||
|
|
@ -125,6 +125,25 @@ class ClientCapabilities implements \JsonSerializable
|
|||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $extensions
|
||||
*/
|
||||
public function withExtensions(array $extensions): self
|
||||
{
|
||||
return new self(
|
||||
$this->roots,
|
||||
$this->rootsListChanged,
|
||||
$this->sampling,
|
||||
$this->elicitation,
|
||||
$this->experimental,
|
||||
array_replace($this->extensions ?? [], $extensions),
|
||||
$this->samplingContext,
|
||||
$this->samplingTools,
|
||||
$this->elicitationForm,
|
||||
$this->elicitationUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* roots?: object,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
namespace Mcp\Schema\Extension\Apps;
|
||||
|
||||
use Mcp\Schema\Extension\ServerExtensionInterface;
|
||||
use Mcp\Schema\Extension\ExtensionInterface;
|
||||
|
||||
/**
|
||||
* The MCP Apps extension (io.modelcontextprotocol/ui).
|
||||
|
|
@ -19,13 +19,14 @@ use Mcp\Schema\Extension\ServerExtensionInterface;
|
|||
* MCP Apps allows servers to expose interactive HTML UI applications as resources.
|
||||
* Clients that support the extension render these in sandboxed iframes.
|
||||
*
|
||||
* Enable on the server via {@see \Mcp\Server\Builder::enableExtension()}.
|
||||
* Enable on the server via {@see \Mcp\Server\Builder::enableExtension()} and on the
|
||||
* client (host) via {@see \Mcp\Client\Builder::enableExtension()}.
|
||||
*
|
||||
* @see https://github.com/modelcontextprotocol/ext-apps
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class McpApps implements ServerExtensionInterface
|
||||
final class McpApps implements ExtensionInterface
|
||||
{
|
||||
public const EXTENSION_ID = 'io.modelcontextprotocol/ui';
|
||||
public const MIME_TYPE = 'text/html;profile=mcp-app';
|
||||
|
|
|
|||
41
src/Schema/Extension/ExtensionInterface.php
Normal file
41
src/Schema/Extension/ExtensionInterface.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?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;
|
||||
|
||||
/**
|
||||
* An MCP protocol extension advertised during capability negotiation.
|
||||
*
|
||||
* Implementations are typically zero-config — they expose a stable identifier and the
|
||||
* capability payload announced under `capabilities.extensions[<id>]`. The same
|
||||
* extension object can be enabled on a server (initialize response) and on a client
|
||||
* (initialize request); the side that enables it decides which.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
interface ExtensionInterface
|
||||
{
|
||||
/**
|
||||
* The reverse-DNS identifier used as the key under `capabilities.extensions`.
|
||||
*/
|
||||
public function getId(): string;
|
||||
|
||||
/**
|
||||
* The capability payload announced for this extension.
|
||||
*
|
||||
* The returned array is cast to an object and embedded under
|
||||
* `capabilities.extensions[<id>]`, so every value must be JSON-serializable
|
||||
* (scalars, arrays, or `JsonSerializable` objects).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getCapabilities(): array;
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ use Mcp\Exception\LogicException;
|
|||
use Mcp\JsonRpc\MessageFactory;
|
||||
use Mcp\Schema\Annotations;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\Extension\ServerExtensionInterface;
|
||||
use Mcp\Schema\Extension\ExtensionInterface;
|
||||
use Mcp\Schema\Icon;
|
||||
use Mcp\Schema\Implementation;
|
||||
use Mcp\Schema\Prompt;
|
||||
|
|
@ -282,7 +282,7 @@ final class Builder
|
|||
*
|
||||
* @throws LogicException if the same extension is enabled more than once
|
||||
*/
|
||||
public function enableExtension(ServerExtensionInterface ...$extensions): self
|
||||
public function enableExtension(ExtensionInterface ...$extensions): self
|
||||
{
|
||||
foreach ($extensions as $extension) {
|
||||
$id = $extension->getId();
|
||||
|
|
|
|||
|
|
@ -349,6 +349,21 @@ class ClientGateway
|
|||
return $this->hasSubCapability('elicitation', 'url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the connected client negotiated the given protocol extension.
|
||||
*
|
||||
* Extensions are advertised under `capabilities.extensions` keyed by their
|
||||
* reverse-DNS id, e.g. `McpApps::EXTENSION_ID` — a server offering MCP Apps
|
||||
* should check this before pointing a tool at a `ui://` resource, and fall back
|
||||
* to a text-only result otherwise.
|
||||
*
|
||||
* @return bool True if the client advertised the extension, false otherwise
|
||||
*/
|
||||
public function supportsExtension(string $id): bool
|
||||
{
|
||||
return $this->hasSubCapability('extensions', $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-capabilities are declared by the presence of a (possibly empty) object, so
|
||||
* only the key matters — not whatever it holds. The value arrives as an object on
|
||||
|
|
|
|||
|
|
@ -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