[Schema][Server] Add MCP Apps extension (io.modelcontextprotocol/ui) support (#281)

* feat: add MCP Apps extension (io.modelcontextprotocol/ui) support

Adds support for the MCP Apps protocol extension, which lets servers
expose interactive HTML UIs as resources rendered by spec-conforming
clients (e.g. Goose) in sandboxed iframes.

- Generic extension plumbing: ServerExtensionInterface and
  Builder::enableExtension(), with extensions advertised under
  capabilities.extensions during the initialize handshake.
  ClientCapabilities/ServerCapabilities gain an extensions field.
- Schema DTOs under Mcp\Schema\Extension\Apps: the McpApps marker,
  UiToolMeta, ToolVisibility enum, UiResourceContentMeta,
  UiResourceCsp, and UiResourcePermissions (with empty-object
  presence markers per spec).
- A worked weather example under examples/server/mcp-apps/ with a
  minimal spec-conforming view (ui/initialize handshake, postMessage
  JSONRPCMessage objects, and ResizeObserver-driven size reporting).
- docs/extensions.md guide with pointers from index.md and
  examples.md to the ext-apps repository for the TypeScript SDK and
  richer view-side patterns.
- Unit and inspector snapshot test coverage.

Spec: https://github.com/modelcontextprotocol/ext-apps

* Type UiToolMeta visibility as ToolVisibility enum

Also fix UiResourceContentMeta::jsonSerialize() return phpdoc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address review nitpicks for MCP Apps extension

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Apply PR review feedback for MCP Apps extension

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add regression tests and CHANGELOG entry for capabilities extensions

- Pin the is_array() guards on extensions in ServerCapabilities and
  ClientCapabilities::fromArray() with explicit non-array payload tests.
- Pin the isset()-rejects-null contract in UiResourcePermissions::fromArray().
- Surface the new extensions field on both capability classes in CHANGELOG.
- Inline the ?: idiom in Builder::build() to match the surrounding style.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christopher Hertel 2026-06-01 09:35:54 +01:00 • committed by GitHub
commit d150772e24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1632 additions and 3 deletions

View file

@ -20,7 +20,8 @@ namespace Mcp\Schema;
class ClientCapabilities implements \JsonSerializable
{
/**
* @param array<string, mixed> $experimental
* @param array<string, mixed> $experimental
* @param ?array<string, mixed> $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui)
*/
public function __construct(
public readonly ?bool $roots = false,
@ -28,6 +29,7 @@ class ClientCapabilities implements \JsonSerializable
public readonly ?bool $sampling = null,
public readonly ?bool $elicitation = null,
public readonly ?array $experimental = null,
public readonly ?array $extensions = null,
) {
}
@ -39,6 +41,7 @@ class ClientCapabilities implements \JsonSerializable
* sampling?: bool,
* elicitation?: bool,
* experimental?: array<string, mixed>,
* extensions?: array<string, mixed>,
* } $data
*/
public static function fromArray(array $data): self
@ -68,7 +71,8 @@ class ClientCapabilities implements \JsonSerializable
$rootsListChanged,
$sampling,
$elicitation,
$data['experimental'] ?? null
\is_array($data['experimental'] ?? null) ? $data['experimental'] : null,
\is_array($data['extensions'] ?? null) ? $data['extensions'] : null,
);
}
@ -78,6 +82,7 @@ class ClientCapabilities implements \JsonSerializable
* sampling?: object,
* elicitation?: object,
* experimental?: object,
* extensions?: object,
* }|\stdClass
*/
public function jsonSerialize(): array|object
@ -102,6 +107,10 @@ class ClientCapabilities implements \JsonSerializable
$data['experimental'] = (object) $this->experimental;
}
if ($this->extensions) {
$data['extensions'] = (object) $this->extensions;
}
return $data ?: new \stdClass();
}
}

View file

@ -0,0 +1,58 @@
<?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\Apps;
use Mcp\Schema\Extension\ServerExtensionInterface;
/**
* The MCP Apps extension (io.modelcontextprotocol/ui).
*
* 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()}.
*
* @see https://github.com/modelcontextprotocol/ext-apps
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class McpApps implements ServerExtensionInterface
{
public const EXTENSION_ID = 'io.modelcontextprotocol/ui';
public const MIME_TYPE = 'text/html;profile=mcp-app';
public const URI_SCHEME = 'ui';
public function getId(): string
{
return self::EXTENSION_ID;
}
/**
* @return array{mimeTypes: string[]}
*/
public function getCapabilities(): array
{
return ['mimeTypes' => [self::MIME_TYPE]];
}
/**
* The marker value for the `_meta.ui` field on a UI resource *descriptor*
* (its `resources/list` entry), flagging the resource as an MCP App.
*
* The structured CSP/permissions metadata instead belongs on the resource
* *content* (the `resources/read` payload) via {@see UiResourceContentMeta}.
*/
public static function resourceMarker(): \stdClass
{
return new \stdClass();
}
}

View file

@ -0,0 +1,26 @@
<?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\Apps;
/**
* Controls who can see and invoke a tool linked to a UI resource in an MCP App.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
enum ToolVisibility: string
{
/** Visible to and callable by the LLM agent. */
case Model = 'model';
/** Callable by the MCP App (HTML view) only, hidden from the model's tools/list. */
case App = 'app';
}

View file

@ -0,0 +1,81 @@
<?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\Apps;
/**
* Metadata for the _meta.ui field on resource content in a resources/read response.
*
* This describes the security and display requirements for the rendered HTML app.
*
* @phpstan-import-type UiResourceCspData from UiResourceCsp
* @phpstan-import-type UiResourcePermissionsData from UiResourcePermissions
*
* @phpstan-type UiResourceContentMetaData array{
* csp?: UiResourceCspData,
* permissions?: UiResourcePermissionsData,
* domain?: string,
* prefersBorder?: bool
* }
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class UiResourceContentMeta implements \JsonSerializable
{
public function __construct(
public readonly ?UiResourceCsp $csp = null,
public readonly ?UiResourcePermissions $permissions = null,
public readonly ?string $domain = null,
public readonly ?bool $prefersBorder = null,
) {
}
/**
* @param UiResourceContentMetaData $data
*/
public static function fromArray(array $data): self
{
return new self(
csp: isset($data['csp']) ? UiResourceCsp::fromArray($data['csp']) : null,
permissions: isset($data['permissions']) ? UiResourcePermissions::fromArray($data['permissions']) : null,
domain: $data['domain'] ?? null,
prefersBorder: $data['prefersBorder'] ?? null,
);
}
/**
* @return array{
* csp?: UiResourceCsp,
* permissions?: UiResourcePermissions,
* domain?: string,
* prefersBorder?: bool
* }
*/
public function jsonSerialize(): array
{
$data = [];
if (null !== $this->csp) {
$data['csp'] = $this->csp;
}
if (null !== $this->permissions) {
$data['permissions'] = $this->permissions;
}
if (null !== $this->domain) {
$data['domain'] = $this->domain;
}
if (null !== $this->prefersBorder) {
$data['prefersBorder'] = $this->prefersBorder;
}
return $data;
}
}

View file

@ -0,0 +1,82 @@
<?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\Apps;
/**
* Content Security Policy configuration for MCP App resources.
*
* Controls which external domains the rendered HTML app can access.
* If omitted entirely, a restrictive default policy is applied by the host.
*
* @phpstan-type UiResourceCspData array{
* connectDomains?: string[],
* resourceDomains?: string[],
* frameDomains?: string[],
* baseUriDomains?: string[]
* }
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class UiResourceCsp implements \JsonSerializable
{
/**
* @param ?string[] $connectDomains domains allowed for network requests (fetch, XHR, WebSocket)
* @param ?string[] $resourceDomains domains allowed for static resources (images, scripts, styles)
* @param ?string[] $frameDomains domains allowed for nested iframes
* @param ?string[] $baseUriDomains domains allowed for base URI origins
*/
public function __construct(
public readonly ?array $connectDomains = null,
public readonly ?array $resourceDomains = null,
public readonly ?array $frameDomains = null,
public readonly ?array $baseUriDomains = null,
) {
}
/**
* @param UiResourceCspData $data
*/
public static function fromArray(array $data): self
{
return new self(
connectDomains: $data['connectDomains'] ?? null,
resourceDomains: $data['resourceDomains'] ?? null,
frameDomains: $data['frameDomains'] ?? null,
baseUriDomains: $data['baseUriDomains'] ?? null,
);
}
/**
* @return UiResourceCspData
*/
public function jsonSerialize(): array
{
$data = [];
// The MCP Apps spec (2026-01-26) defines "empty or omitted" identically
// for every CSP allow-list, so empty arrays are dropped, not emitted as `[]`.
if ($this->connectDomains) {
$data['connectDomains'] = $this->connectDomains;
}
if ($this->resourceDomains) {
$data['resourceDomains'] = $this->resourceDomains;
}
if ($this->frameDomains) {
$data['frameDomains'] = $this->frameDomains;
}
if ($this->baseUriDomains) {
$data['baseUriDomains'] = $this->baseUriDomains;
}
return $data;
}
}

View file

@ -0,0 +1,76 @@
<?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\Apps;
/**
* Sandbox permissions that an MCP App resource requests from the host.
*
* Per spec each requested permission is a presence marker (serialized as `{}`),
* so the wire shape is e.g. `{"geolocation": {}}`, not `{"geolocation": true}`.
*
* @phpstan-type UiResourcePermissionsData array{
* camera?: \stdClass|array<string, mixed>,
* microphone?: \stdClass|array<string, mixed>,
* geolocation?: \stdClass|array<string, mixed>,
* clipboardWrite?: \stdClass|array<string, mixed>
* }
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class UiResourcePermissions implements \JsonSerializable
{
public function __construct(
public readonly bool $camera = false,
public readonly bool $microphone = false,
public readonly bool $geolocation = false,
public readonly bool $clipboardWrite = false,
) {
}
/**
* @param UiResourcePermissionsData $data
*/
public static function fromArray(array $data): self
{
// A permission is requested when its key is present with the spec's `{}`
// marker; isset() accepts that (array/object forms) and rejects a stray null.
return new self(
camera: isset($data['camera']),
microphone: isset($data['microphone']),
geolocation: isset($data['geolocation']),
clipboardWrite: isset($data['clipboardWrite']),
);
}
/**
* @return array<string, \stdClass>
*/
public function jsonSerialize(): array
{
$data = [];
if ($this->camera) {
$data['camera'] = new \stdClass();
}
if ($this->microphone) {
$data['microphone'] = new \stdClass();
}
if ($this->geolocation) {
$data['geolocation'] = new \stdClass();
}
if ($this->clipboardWrite) {
$data['clipboardWrite'] = new \stdClass();
}
return $data;
}
}

View file

@ -0,0 +1,64 @@
<?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\Apps;
/**
* Metadata for the _meta.ui field on a Tool, linking it to a UI resource.
*
* @phpstan-type UiToolMetaData array{
* resourceUri?: string,
* visibility?: string[]
* }
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class UiToolMeta implements \JsonSerializable
{
/**
* @param ?string $resourceUri the ui:// URI of the linked UI resource
* @param ?list<ToolVisibility> $visibility who can see/call this tool; when omitted the host
* defaults to both {@see ToolVisibility::Model} and {@see ToolVisibility::App}
*/
public function __construct(
public readonly ?string $resourceUri = null,
public readonly ?array $visibility = null,
) {
}
/**
* @param UiToolMetaData $data
*/
public static function fromArray(array $data): self
{
return new self(
resourceUri: $data['resourceUri'] ?? null,
visibility: isset($data['visibility']) ? array_map(ToolVisibility::from(...), $data['visibility']) : null,
);
}
/**
* @return UiToolMetaData
*/
public function jsonSerialize(): array
{
$data = [];
if (null !== $this->resourceUri) {
$data['resourceUri'] = $this->resourceUri;
}
if (null !== $this->visibility) {
$data['visibility'] = array_map(static fn (ToolVisibility $v): string => $v->value, $this->visibility);
}
return $data;
}
}

View file

@ -0,0 +1,40 @@
<?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;
/**
* A server-side 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>]` in the initialize
* response.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
interface ServerExtensionInterface
{
/**
* 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>]` in the initialize response, so every value
* must be JSON-serializable (scalars, arrays, or `JsonSerializable` objects).
*
* @return array<string, mixed>
*/
public function getCapabilities(): array;
}

View file

@ -30,6 +30,7 @@ class ServerCapabilities implements \JsonSerializable
* @param ?bool $logging server emits structured log messages
* @param ?bool $completions Server supports argument autocompletion
* @param ?array<string, mixed> $experimental experimental, non-standard features that the server supports
* @param ?array<string, mixed> $extensions protocol extensions the server supports (e.g. io.modelcontextprotocol/ui)
*/
public function __construct(
public readonly ?bool $tools = true,
@ -42,6 +43,7 @@ class ServerCapabilities implements \JsonSerializable
public readonly ?bool $logging = false,
public readonly ?bool $completions = false,
public readonly ?array $experimental = null,
public readonly ?array $extensions = null,
) {
}
@ -53,6 +55,7 @@ class ServerCapabilities implements \JsonSerializable
* resources?: array{listChanged?: bool, subscribe?: bool}|object,
* tools?: object|array{listChanged?: bool},
* experimental?: array<string, mixed>,
* extensions?: array<string, mixed>,
* } $data
*/
public static function fromArray(array $data): self
@ -106,7 +109,32 @@ class ServerCapabilities implements \JsonSerializable
promptsListChanged: $promptsListChanged,
logging: $loggingEnabled,
completions: $completionsEnabled,
experimental: $data['experimental'] ?? null,
experimental: \is_array($data['experimental'] ?? null) ? $data['experimental'] : null,
extensions: \is_array($data['extensions'] ?? null) ? $data['extensions'] : null,
);
}
/**
* Returns a copy with the given protocol extensions merged into the existing ones.
*
* Entries in $extensions override existing ones sharing the same id.
*
* @param array<string, array<string, mixed>> $extensions
*/
public function withExtensions(array $extensions): self
{
return new self(
$this->tools,
$this->toolsListChanged,
$this->resources,
$this->resourcesSubscribe,
$this->resourcesListChanged,
$this->prompts,
$this->promptsListChanged,
$this->logging,
$this->completions,
$this->experimental,
[...$this->extensions ?? [], ...$extensions],
);
}
@ -118,6 +146,7 @@ class ServerCapabilities implements \JsonSerializable
* resources?: object,
* tools?: object,
* experimental?: object,
* extensions?: object,
* }
*/
public function jsonSerialize(): array
@ -159,6 +188,10 @@ class ServerCapabilities implements \JsonSerializable
$data['experimental'] = (object) $this->experimental;
}
if ($this->extensions) {
$data['extensions'] = (object) $this->extensions;
}
return $data;
}
}

View file

@ -28,9 +28,11 @@ use Mcp\Capability\Registry\ReferenceHandler;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Capability\RegistryInterface;
use Mcp\Exception\InvalidArgumentException;
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\Icon;
use Mcp\Schema\Implementation;
use Mcp\Schema\Prompt;
@ -207,6 +209,11 @@ final class Builder
private ?ServerCapabilities $serverCapabilities = null;
/**
* @var array<string, array<string, mixed>>
*/
private array $extensions = [];
/**
* @var LoaderInterface[]
*/
@ -263,6 +270,27 @@ final class Builder
return $this;
}
/**
* Enable one or more MCP protocol extensions, announced to clients under
* `capabilities.extensions` during the initialize handshake.
*
* @throws LogicException if the same extension is enabled more than once
*/
public function enableExtension(ServerExtensionInterface ...$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;
}
/**
* Register a single custom method handler.
*
@ -661,8 +689,15 @@ final class Builder
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
logging: true,
completions: true,
extensions: $this->extensions ?: null,
);
// Extensions enabled via enableExtension() are folded into caller-supplied
// capabilities too, so setCapabilities() does not silently drop them.
if (null !== $this->serverCapabilities && [] !== $this->extensions) {
$capabilities = $capabilities->withExtensions($this->extensions);
}
$serverInfo = $this->serverInfo ?? new Implementation();
$configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion);
$referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container);