[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
|
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.
|
* 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 `[]`.
|
* 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.
|
* 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();
|
->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
|
be enabled in a single call. Enabling the same extension twice throws a
|
||||||
`LogicException`.
|
`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
|
> Note: extensions enabled via `enableExtension()` are merged into the
|
||||||
> `extensions` capability even when you supply your own `ServerCapabilities` via
|
> `extensions` capability even when you supply your own `ServerCapabilities` /
|
||||||
> `setCapabilities()`. An enabled extension overrides any entry under the same
|
> `ClientCapabilities` via `setCapabilities()`. An enabled extension overrides
|
||||||
> id already present in those capabilities.
|
> 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`)
|
## 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
|
carries the structured `UiResourceContentMeta` with the actual CSP and permission
|
||||||
configuration.
|
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
|
### Server-side DTOs
|
||||||
|
|
||||||
| Class | Purpose |
|
| Class | Purpose |
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ use Mcp\Schema\Extension\Apps\McpApps;
|
||||||
use Mcp\Schema\Extension\Apps\UiResourceContentMeta;
|
use Mcp\Schema\Extension\Apps\UiResourceContentMeta;
|
||||||
use Mcp\Schema\Extension\Apps\UiResourceCsp;
|
use Mcp\Schema\Extension\Apps\UiResourceCsp;
|
||||||
use Mcp\Schema\Extension\Apps\UiResourcePermissions;
|
use Mcp\Schema\Extension\Apps\UiResourcePermissions;
|
||||||
|
use Mcp\Server\RequestContext;
|
||||||
|
|
||||||
final class WeatherApp
|
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 = [
|
$weather = [
|
||||||
'london' => ['temp' => '15°C', 'condition' => 'Cloudy', 'humidity' => '78%'],
|
'london' => ['temp' => '15°C', 'condition' => 'Cloudy', 'humidity' => '78%'],
|
||||||
|
|
@ -56,12 +57,20 @@ final class WeatherApp
|
||||||
$key = strtolower($city);
|
$key = strtolower($city);
|
||||||
$data = $weather[$key] ?? ['temp' => '20°C', 'condition' => 'Clear', 'humidity' => '60%'];
|
$data = $weather[$key] ?? ['temp' => '20°C', 'condition' => 'Clear', 'humidity' => '60%'];
|
||||||
|
|
||||||
return \sprintf(
|
$text = \sprintf(
|
||||||
'Weather in %s: %s, %s, Humidity: %s',
|
'Weather in %s: %s, %s, Humidity: %s',
|
||||||
$city,
|
$city,
|
||||||
$data['temp'],
|
$data['temp'],
|
||||||
$data['condition'],
|
$data['condition'],
|
||||||
$data['humidity'],
|
$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;
|
||||||
use Mcp\Client\Handler\Notification\NotificationHandlerInterface;
|
use Mcp\Client\Handler\Notification\NotificationHandlerInterface;
|
||||||
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||||
|
use Mcp\Exception\LogicException;
|
||||||
use Mcp\Schema\ClientCapabilities;
|
use Mcp\Schema\ClientCapabilities;
|
||||||
use Mcp\Schema\Enum\ProtocolVersion;
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
|
use Mcp\Schema\Extension\ExtensionInterface;
|
||||||
use Mcp\Schema\Implementation;
|
use Mcp\Schema\Implementation;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
|
|
@ -33,6 +35,9 @@ final class Builder
|
||||||
private ?string $title = null;
|
private ?string $title = null;
|
||||||
private ?ProtocolVersion $protocolVersion = null;
|
private ?ProtocolVersion $protocolVersion = null;
|
||||||
private ?ClientCapabilities $capabilities = null;
|
private ?ClientCapabilities $capabilities = null;
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> */
|
||||||
|
private array $extensions = [];
|
||||||
private int $initTimeout = 30;
|
private int $initTimeout = 30;
|
||||||
private int $requestTimeout = 120;
|
private int $requestTimeout = 120;
|
||||||
private int $maxRetries = 3;
|
private int $maxRetries = 3;
|
||||||
|
|
@ -79,6 +84,27 @@ final class Builder
|
||||||
return $this;
|
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.
|
* Set initialization timeout in seconds.
|
||||||
*/
|
*/
|
||||||
|
|
@ -159,9 +185,17 @@ final class Builder
|
||||||
title: $this->title,
|
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(
|
$config = new Configuration(
|
||||||
clientInfo: $clientInfo,
|
clientInfo: $clientInfo,
|
||||||
capabilities: $this->capabilities ?? new ClientCapabilities(),
|
capabilities: $capabilities,
|
||||||
protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_11_25,
|
protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_11_25,
|
||||||
initTimeout: $this->initTimeout,
|
initTimeout: $this->initTimeout,
|
||||||
requestTimeout: $this->requestTimeout,
|
requestTimeout: $this->requestTimeout,
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,25 @@ class ClientCapabilities implements \JsonSerializable
|
||||||
return false;
|
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{
|
* @return array{
|
||||||
* roots?: object,
|
* roots?: object,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
namespace Mcp\Schema\Extension\Apps;
|
namespace Mcp\Schema\Extension\Apps;
|
||||||
|
|
||||||
use Mcp\Schema\Extension\ServerExtensionInterface;
|
use Mcp\Schema\Extension\ExtensionInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The MCP Apps extension (io.modelcontextprotocol/ui).
|
* 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.
|
* MCP Apps allows servers to expose interactive HTML UI applications as resources.
|
||||||
* Clients that support the extension render these in sandboxed iframes.
|
* 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
|
* @see https://github.com/modelcontextprotocol/ext-apps
|
||||||
*
|
*
|
||||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
* @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 EXTENSION_ID = 'io.modelcontextprotocol/ui';
|
||||||
public const MIME_TYPE = 'text/html;profile=mcp-app';
|
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\JsonRpc\MessageFactory;
|
||||||
use Mcp\Schema\Annotations;
|
use Mcp\Schema\Annotations;
|
||||||
use Mcp\Schema\Enum\ProtocolVersion;
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
use Mcp\Schema\Extension\ServerExtensionInterface;
|
use Mcp\Schema\Extension\ExtensionInterface;
|
||||||
use Mcp\Schema\Icon;
|
use Mcp\Schema\Icon;
|
||||||
use Mcp\Schema\Implementation;
|
use Mcp\Schema\Implementation;
|
||||||
use Mcp\Schema\Prompt;
|
use Mcp\Schema\Prompt;
|
||||||
|
|
@ -282,7 +282,7 @@ final class Builder
|
||||||
*
|
*
|
||||||
* @throws LogicException if the same extension is enabled more than once
|
* @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) {
|
foreach ($extensions as $extension) {
|
||||||
$id = $extension->getId();
|
$id = $extension->getId();
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,21 @@ class ClientGateway
|
||||||
return $this->hasSubCapability('elicitation', 'url');
|
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
|
* 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
|
* 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\Completion\ListCompletionProvider;
|
||||||
use Mcp\Capability\Discovery\Discoverer;
|
use Mcp\Capability\Discovery\Discoverer;
|
||||||
use Mcp\Capability\Registry\ToolReference;
|
use Mcp\Capability\Registry\ToolReference;
|
||||||
|
use Mcp\Schema\Extension\Apps\McpApps;
|
||||||
use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture;
|
use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture;
|
||||||
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\AlternativeFileNameToolHandler;
|
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\AlternativeFileNameToolHandler;
|
||||||
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler;
|
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler;
|
||||||
|
|
@ -38,7 +39,7 @@ class DiscoveryTest extends TestCase
|
||||||
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']);
|
$discovery = $this->discoverer->discover(__DIR__, ['Fixtures'], [], ['*.php', '*.inc']);
|
||||||
|
|
||||||
$tools = $discovery->getTools();
|
$tools = $discovery->getTools();
|
||||||
$this->assertCount(5, $tools);
|
$this->assertCount(6, $tools);
|
||||||
|
|
||||||
$this->assertArrayHasKey('greet_user', $tools);
|
$this->assertArrayHasKey('greet_user', $tools);
|
||||||
$this->assertEquals('greet_user', $tools['greet_user']->tool->name);
|
$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);
|
$this->assertArrayNotHasKey('static_tool_should_be_ignored', $tools);
|
||||||
|
|
||||||
$resources = $discovery->getResources();
|
$resources = $discovery->getResources();
|
||||||
$this->assertCount(3, $resources);
|
$this->assertCount(4, $resources);
|
||||||
|
|
||||||
$this->assertArrayHasKey('app://info/version', $resources);
|
$this->assertArrayHasKey('app://info/version', $resources);
|
||||||
$this->assertEquals('app_version', $resources['app://info/version']->resource->name);
|
$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->assertArrayHasKey('invokable://config/status', $resources);
|
||||||
$this->assertEquals([InvocableResourceFixture::class, '__invoke'], $resources['invokable://config/status']->handler);
|
$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();
|
$prompts = $discovery->getPrompts();
|
||||||
$this->assertCount(4, $prompts);
|
$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;
|
||||||
use Mcp\Client\Configuration;
|
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\Attributes\TestDox;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
|
@ -43,6 +46,41 @@ final class BuilderTest extends TestCase
|
||||||
$this->assertNull($this->extractConfiguration($client)->clientInfo->title);
|
$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
|
private function extractConfiguration(Client $client): Configuration
|
||||||
{
|
{
|
||||||
$config = (new \ReflectionClass($client))->getProperty('config')->getValue($client);
|
$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\UiResourceCsp;
|
||||||
use Mcp\Schema\Extension\Apps\UiResourcePermissions;
|
use Mcp\Schema\Extension\Apps\UiResourcePermissions;
|
||||||
use Mcp\Schema\Extension\Apps\UiToolMeta;
|
use Mcp\Schema\Extension\Apps\UiToolMeta;
|
||||||
|
use Mcp\Schema\Extension\ExtensionInterface;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class McpAppsTest extends TestCase
|
class McpAppsTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testServerExtensionInterface(): void
|
public function testExtensionInterface(): void
|
||||||
{
|
{
|
||||||
$extension = new McpApps();
|
$extension = new McpApps();
|
||||||
|
|
||||||
|
$this->assertInstanceOf(ExtensionInterface::class, $extension);
|
||||||
$this->assertSame('io.modelcontextprotocol/ui', $extension->getId());
|
$this->assertSame('io.modelcontextprotocol/ui', $extension->getId());
|
||||||
$this->assertSame(['mimeTypes' => ['text/html;profile=mcp-app']], $extension->getCapabilities());
|
$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');
|
$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
|
public function testClientCapabilitiesWithExtensions(): void
|
||||||
{
|
{
|
||||||
$extensions = [
|
$extensions = [
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use Mcp\Schema\Elicitation\ElicitationSchema;
|
||||||
use Mcp\Schema\Elicitation\StringSchemaDefinition;
|
use Mcp\Schema\Elicitation\StringSchemaDefinition;
|
||||||
use Mcp\Schema\Enum\ElicitAction;
|
use Mcp\Schema\Enum\ElicitAction;
|
||||||
use Mcp\Schema\Enum\ElicitationMode;
|
use Mcp\Schema\Enum\ElicitationMode;
|
||||||
|
use Mcp\Schema\Extension\Apps\McpApps;
|
||||||
use Mcp\Schema\JsonRpc\Error;
|
use Mcp\Schema\JsonRpc\Error;
|
||||||
use Mcp\Schema\JsonRpc\Request;
|
use Mcp\Schema\JsonRpc\Request;
|
||||||
use Mcp\Schema\JsonRpc\Response;
|
use Mcp\Schema\JsonRpc\Response;
|
||||||
|
|
@ -86,6 +87,17 @@ final class ClientGatewayTest extends TestCase
|
||||||
$this->assertTrue($this->gatewayFor((array) $capabilities)->supportsSamplingTools());
|
$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
|
public function testSupportsElicitationUrlReflectsTheSubCapability(): void
|
||||||
{
|
{
|
||||||
$this->assertTrue($this->gatewayFor(['elicitation' => ['url' => []]])->supportsElicitationUrl());
|
$this->assertTrue($this->gatewayFor(['elicitation' => ['url' => []]])->supportsElicitationUrl());
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue