[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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue