[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:
Christopher Hertel 2026-08-18 01:46:53 +02:00 • committed by GitHub
commit 6f58fe596a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 273 additions and 21 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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';

View 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;
}

View file

@ -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();

View file

@ -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