diff --git a/composer.json b/composer.json
index d9f1507f..b883603b 100644
--- a/composer.json
+++ b/composer.json
@@ -76,6 +76,7 @@
"Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/",
"Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/",
"Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/",
+ "Mcp\\Example\\Server\\McpApps\\": "examples/server/mcp-apps/",
"Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/",
"Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/",
"Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/",
diff --git a/docs/examples.md b/docs/examples.md
index 4475f131..61b788e9 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -355,6 +355,18 @@ npx @modelcontextprotocol/inspector php examples/server/elicitation/server.php
2. **confirm_action** - Simple boolean confirmation dialog
3. **collect_feedback** - Rating and comments form with optional fields
+### MCP Apps
+
+**File**: `examples/server/mcp-apps/`
+
+A weather app demonstrating the [MCP Apps extension](extensions.md): a `ui://`
+HTML resource is opened by an MCP App-aware client (e.g. Goose) and bridged to
+the `get_weather` tool. The bundled `weather-app.html` performs the
+`ui/initialize` handshake, reports its size via `ui/notifications/size-changed`,
+and calls back into the server. See the
+[ext-apps repo](https://github.com/modelcontextprotocol/ext-apps) for the
+TypeScript SDK and richer view-side patterns.
+
## Client Examples
### STDIO Discovery Calculator (Client)
diff --git a/docs/extensions.md b/docs/extensions.md
new file mode 100644
index 00000000..6f3593b6
--- /dev/null
+++ b/docs/extensions.md
@@ -0,0 +1,103 @@
+# Protocol Extensions
+
+MCP protocol extensions advertise additional, optional capabilities during the initialize handshake.
+A server opts in via `Builder::enableExtension()`:
+
+```php
+use Mcp\Schema\Extension\Apps\McpApps;
+use Mcp\Server;
+
+$server = Server::builder()
+ ->setServerInfo('My Server', '1.0.0')
+ ->enableExtension(McpApps::class) // or pre-built instances
+ ->build();
+```
+
+Pass either a class string (the extension is instantiated with no arguments) or
+a pre-built `ServerExtensionInterface` instance. Multiple extensions can be
+enabled in a single call.
+
+> Note: calling `setCapabilities()` overrides automatic capability detection,
+> so it also overrides the `extensions` field. If you set your own
+> `ServerCapabilities`, include the extensions you want yourself.
+
+## MCP Apps (`io.modelcontextprotocol/ui`)
+
+The [MCP Apps extension][ext-apps] lets servers expose interactive HTML UIs as
+resources. Clients that support it render them in sandboxed iframes and bridge
+tool calls between the iframe (the *View*) and the server via the host.
+
+A UI consists of two pieces wired together by `_meta.ui`:
+
+1. **A resource** with URI scheme `ui://` and MIME type
+ `text/html;profile=mcp-app`, returning the HTML body.
+2. **A tool** linked to that resource via `UiToolMeta`, so the client knows to
+ open the UI when the tool is invoked.
+
+```php
+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\UiResourcePermissions;
+use Mcp\Schema\Extension\Apps\UiToolMeta;
+
+$server = Server::builder()
+ ->enableExtension(McpApps::class)
+ ->addResource(
+ fn () => new TextResourceContents(
+ uri: 'ui://my-app',
+ mimeType: McpApps::MIME_TYPE,
+ text: file_get_contents(__DIR__.'/app.html'),
+ meta: ['ui' => new UiResourceContentMeta(
+ csp: new UiResourceCsp(connectDomains: ['https://api.example.com']),
+ permissions: new UiResourcePermissions(geolocation: true),
+ prefersBorder: true,
+ )],
+ ),
+ 'ui://my-app',
+ mimeType: McpApps::MIME_TYPE,
+ meta: ['ui' => new \stdClass()],
+ )
+ ->addTool(
+ $myToolHandler,
+ 'my_tool',
+ meta: ['ui' => new UiToolMeta(
+ resourceUri: 'ui://my-app',
+ visibility: [ToolVisibility::Model->value, ToolVisibility::App->value],
+ )],
+ )
+ ->build();
+```
+
+### Server-side DTOs
+
+| Class | Purpose |
+| --- | --- |
+| `McpApps` | Extension marker; provides `EXTENSION_ID`, `MIME_TYPE`, `URI_SCHEME` constants. |
+| `UiToolMeta` | Tool `_meta.ui` payload: `resourceUri` + `visibility`. |
+| `ToolVisibility` | Enum: `Model`, `App`. |
+| `UiResourceContentMeta` | Resource content `_meta.ui`: `csp`, `permissions`, `domain`, `prefersBorder`. |
+| `UiResourceCsp` | CSP allow-lists: `connectDomains`, `resourceDomains`, `frameDomains`, `baseUriDomains`. |
+| `UiResourcePermissions` | Sandbox permissions: `camera`, `microphone`, `geolocation`, `clipboardWrite`. |
+
+### Writing the HTML view
+
+The View and host exchange `JSONRPCMessage` **objects** (not JSON strings) via
+`window.parent.postMessage`. Before the host forwards `tools/call`,
+`tool-input`, or `tool-result`, the View must complete the spec-mandated
+handshake:
+
+1. View → Host: `ui/initialize` request
+2. Host → View: response with `hostCapabilities`, `hostInfo`, `hostContext`
+3. View → Host: `ui/notifications/initialized`
+4. View → Host: `ui/notifications/size-changed` whenever the iframe wants to
+ resize
+
+See the [`ext-apps` repository][ext-apps] for the full protocol, official
+TypeScript SDK (`@modelcontextprotocol/ext-apps`), and view-side examples. A
+working minimal view is included in
+[`examples/server/mcp-apps/weather-app.html`](../examples/server/mcp-apps/weather-app.html).
+
+[ext-apps]: https://github.com/modelcontextprotocol/ext-apps
diff --git a/docs/index.md b/docs/index.md
index 86a30185..9ace7bdd 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,4 +5,5 @@
- [Client](client.md) — Client SDK for connecting to and communicating with MCP servers.
- [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them.
- [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications.
+- [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources).
- [Examples](examples.md) — Example projects demonstrating attribute-based discovery, dependency injection, HTTP transport, and more.
diff --git a/examples/server/mcp-apps/WeatherApp.php b/examples/server/mcp-apps/WeatherApp.php
new file mode 100644
index 00000000..5facead6
--- /dev/null
+++ b/examples/server/mcp-apps/WeatherApp.php
@@ -0,0 +1,67 @@
+ $contentMeta],
+ );
+ }
+
+ public function getWeather(string $city): string
+ {
+ $weather = [
+ 'london' => ['temp' => '15°C', 'condition' => 'Cloudy', 'humidity' => '78%'],
+ 'paris' => ['temp' => '18°C', 'condition' => 'Sunny', 'humidity' => '55%'],
+ 'tokyo' => ['temp' => '22°C', 'condition' => 'Partly Cloudy', 'humidity' => '65%'],
+ 'new york' => ['temp' => '12°C', 'condition' => 'Rainy', 'humidity' => '85%'],
+ 'lagos' => ['temp' => '30°C', 'condition' => 'Sunny', 'humidity' => '82%'],
+ 'stockholm' => ['temp' => '4°C', 'condition' => 'Cloudy', 'humidity' => '70%'],
+ 'berlin' => ['temp' => '9°C', 'condition' => 'Partly Cloudy', 'humidity' => '68%'],
+ 'sydney' => ['temp' => '26°C', 'condition' => 'Sunny', 'humidity' => '60%'],
+ 'buenos aires' => ['temp' => '24°C', 'condition' => 'Rainy', 'humidity' => '80%'],
+ ];
+
+ $key = strtolower($city);
+ $data = $weather[$key] ?? ['temp' => '20°C', 'condition' => 'Clear', 'humidity' => '60%'];
+
+ return \sprintf(
+ 'Weather in %s: %s, %s, Humidity: %s',
+ $city,
+ $data['temp'],
+ $data['condition'],
+ $data['humidity'],
+ );
+ }
+}
diff --git a/examples/server/mcp-apps/server.php b/examples/server/mcp-apps/server.php
new file mode 100644
index 00000000..b1165415
--- /dev/null
+++ b/examples/server/mcp-apps/server.php
@@ -0,0 +1,51 @@
+#!/usr/bin/env php
+info('Starting MCP Apps Example Server...');
+
+$server = Server::builder()
+ ->setServerInfo('MCP Apps Weather Example', '1.0.0')
+ ->setLogger(logger())
+ ->enableExtension(McpApps::class)
+ ->addResource(
+ [WeatherApp::class, 'getWeatherApp'],
+ 'ui://weather-app',
+ 'weather-app',
+ description: 'Interactive weather dashboard',
+ mimeType: McpApps::MIME_TYPE,
+ meta: ['ui' => new stdClass()],
+ )
+ ->addTool(
+ [WeatherApp::class, 'getWeather'],
+ 'get_weather',
+ description: 'Get current weather for a city',
+ meta: ['ui' => new UiToolMeta(
+ resourceUri: 'ui://weather-app',
+ visibility: [ToolVisibility::Model->value, ToolVisibility::App->value],
+ )],
+ )
+ ->build();
+
+$result = $server->run(transport());
+
+logger()->info('Server stopped gracefully.', ['result' => $result]);
+
+shutdown($result);
diff --git a/examples/server/mcp-apps/weather-app.html b/examples/server/mcp-apps/weather-app.html
new file mode 100644
index 00000000..83d61b11
--- /dev/null
+++ b/examples/server/mcp-apps/weather-app.html
@@ -0,0 +1,230 @@
+
+
+
+
+
+ Weather Dashboard
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php
index bfe4e6ed..b7bb040b 100644
--- a/src/Schema/ClientCapabilities.php
+++ b/src/Schema/ClientCapabilities.php
@@ -20,7 +20,8 @@
class ClientCapabilities implements \JsonSerializable
{
/**
- * @param array $experimental
+ * @param array $experimental
+ * @param ?array $extensions protocol extensions the client supports (e.g. io.modelcontextprotocol/ui)
*/
public function __construct(
public readonly ?bool $roots = false,
@@ -28,6 +29,7 @@ public function __construct(
public readonly ?bool $sampling = null,
public readonly ?bool $elicitation = null,
public readonly ?array $experimental = null,
+ public readonly ?array $extensions = null,
) {
}
@@ -39,6 +41,7 @@ public function __construct(
* sampling?: bool,
* elicitation?: bool,
* experimental?: array,
+ * extensions?: array,
* } $data
*/
public static function fromArray(array $data): self
@@ -68,7 +71,8 @@ public static function fromArray(array $data): self
$rootsListChanged,
$sampling,
$elicitation,
- $data['experimental'] ?? null
+ $data['experimental'] ?? null,
+ $data['extensions'] ?? null,
);
}
@@ -78,6 +82,7 @@ public static function fromArray(array $data): self
* sampling?: object,
* elicitation?: object,
* experimental?: object,
+ * extensions?: object,
* }|\stdClass
*/
public function jsonSerialize(): array|object
@@ -102,6 +107,10 @@ public function jsonSerialize(): array|object
$data['experimental'] = (object) $this->experimental;
}
+ if ($this->extensions) {
+ $data['extensions'] = (object) $this->extensions;
+ }
+
return $data ?: new \stdClass();
}
}
diff --git a/src/Schema/Extension/Apps/McpApps.php b/src/Schema/Extension/Apps/McpApps.php
new file mode 100644
index 00000000..ab5b32bf
--- /dev/null
+++ b/src/Schema/Extension/Apps/McpApps.php
@@ -0,0 +1,46 @@
+
+ */
+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]];
+ }
+}
diff --git a/src/Schema/Extension/Apps/ToolVisibility.php b/src/Schema/Extension/Apps/ToolVisibility.php
new file mode 100644
index 00000000..bdb12ef1
--- /dev/null
+++ b/src/Schema/Extension/Apps/ToolVisibility.php
@@ -0,0 +1,26 @@
+
+ */
+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';
+}
diff --git a/src/Schema/Extension/Apps/UiResourceContentMeta.php b/src/Schema/Extension/Apps/UiResourceContentMeta.php
new file mode 100644
index 00000000..8573d929
--- /dev/null
+++ b/src/Schema/Extension/Apps/UiResourceContentMeta.php
@@ -0,0 +1,76 @@
+
+ */
+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 UiResourceContentMetaData
+ */
+ 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;
+ }
+}
diff --git a/src/Schema/Extension/Apps/UiResourceCsp.php b/src/Schema/Extension/Apps/UiResourceCsp.php
new file mode 100644
index 00000000..c075ea1c
--- /dev/null
+++ b/src/Schema/Extension/Apps/UiResourceCsp.php
@@ -0,0 +1,80 @@
+
+ */
+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 = [];
+
+ if (null !== $this->connectDomains) {
+ $data['connectDomains'] = $this->connectDomains;
+ }
+ if (null !== $this->resourceDomains) {
+ $data['resourceDomains'] = $this->resourceDomains;
+ }
+ if (null !== $this->frameDomains) {
+ $data['frameDomains'] = $this->frameDomains;
+ }
+ if (null !== $this->baseUriDomains) {
+ $data['baseUriDomains'] = $this->baseUriDomains;
+ }
+
+ return $data;
+ }
+}
diff --git a/src/Schema/Extension/Apps/UiResourcePermissions.php b/src/Schema/Extension/Apps/UiResourcePermissions.php
new file mode 100644
index 00000000..0fe983b3
--- /dev/null
+++ b/src/Schema/Extension/Apps/UiResourcePermissions.php
@@ -0,0 +1,74 @@
+,
+ * microphone?: \stdClass|array,
+ * geolocation?: \stdClass|array,
+ * clipboardWrite?: \stdClass|array
+ * }
+ *
+ * @author Christopher Hertel
+ */
+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
+ {
+ return new self(
+ camera: \array_key_exists('camera', $data),
+ microphone: \array_key_exists('microphone', $data),
+ geolocation: \array_key_exists('geolocation', $data),
+ clipboardWrite: \array_key_exists('clipboardWrite', $data),
+ );
+ }
+
+ /**
+ * @return array
+ */
+ 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;
+ }
+}
diff --git a/src/Schema/Extension/Apps/UiToolMeta.php b/src/Schema/Extension/Apps/UiToolMeta.php
new file mode 100644
index 00000000..434470a6
--- /dev/null
+++ b/src/Schema/Extension/Apps/UiToolMeta.php
@@ -0,0 +1,63 @@
+
+ */
+final class UiToolMeta implements \JsonSerializable
+{
+ /**
+ * @param ?string $resourceUri the ui:// URI of the linked UI resource
+ * @param ?string[] $visibility who can see/call this tool: 'model', 'app', or both (default: ['model', '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: $data['visibility'] ?? null,
+ );
+ }
+
+ /**
+ * @return UiToolMetaData
+ */
+ public function jsonSerialize(): array
+ {
+ $data = [];
+
+ if (null !== $this->resourceUri) {
+ $data['resourceUri'] = $this->resourceUri;
+ }
+ if (null !== $this->visibility) {
+ $data['visibility'] = $this->visibility;
+ }
+
+ return $data;
+ }
+}
diff --git a/src/Schema/Extension/ServerExtensionInterface.php b/src/Schema/Extension/ServerExtensionInterface.php
new file mode 100644
index 00000000..9778fa63
--- /dev/null
+++ b/src/Schema/Extension/ServerExtensionInterface.php
@@ -0,0 +1,36 @@
+]` in the initialize
+ * response.
+ *
+ * @author Christopher Hertel
+ */
+interface ServerExtensionInterface
+{
+ /**
+ * The reverse-DNS identifier used as the key under `capabilities.extensions`.
+ */
+ public function getId(): string;
+
+ /**
+ * The capability payload announced for this extension.
+ *
+ * @return array
+ */
+ public function getCapabilities(): array;
+}
diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php
index 89eec187..af247c99 100644
--- a/src/Schema/ServerCapabilities.php
+++ b/src/Schema/ServerCapabilities.php
@@ -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 $experimental experimental, non-standard features that the server supports
+ * @param ?array $extensions protocol extensions the server supports (e.g. io.modelcontextprotocol/ui)
*/
public function __construct(
public readonly ?bool $tools = true,
@@ -42,6 +43,7 @@ public function __construct(
public readonly ?bool $logging = false,
public readonly ?bool $completions = false,
public readonly ?array $experimental = null,
+ public readonly ?array $extensions = null,
) {
}
@@ -53,6 +55,7 @@ public function __construct(
* resources?: array{listChanged?: bool, subscribe?: bool}|object,
* tools?: object|array{listChanged?: bool},
* experimental?: array,
+ * extensions?: array,
* } $data
*/
public static function fromArray(array $data): self
@@ -107,6 +110,7 @@ public static function fromArray(array $data): self
logging: $loggingEnabled,
completions: $completionsEnabled,
experimental: $data['experimental'] ?? null,
+ extensions: $data['extensions'] ?? null,
);
}
@@ -118,6 +122,7 @@ public static function fromArray(array $data): self
* resources?: object,
* tools?: object,
* experimental?: object,
+ * extensions?: object,
* }
*/
public function jsonSerialize(): array
@@ -159,6 +164,10 @@ public function jsonSerialize(): array
$data['experimental'] = (object) $this->experimental;
}
+ if ($this->extensions) {
+ $data['extensions'] = (object) $this->extensions;
+ }
+
return $data;
}
}
diff --git a/src/Server/Builder.php b/src/Server/Builder.php
index 280272e6..1abfcc31 100644
--- a/src/Server/Builder.php
+++ b/src/Server/Builder.php
@@ -28,6 +28,7 @@
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\ServerCapabilities;
@@ -167,6 +168,11 @@ final class Builder
private ?ServerCapabilities $serverCapabilities = null;
+ /**
+ * @var array>
+ */
+ private array $extensions = [];
+
/**
* @var LoaderInterface[]
*/
@@ -223,6 +229,31 @@ public function setCapabilities(ServerCapabilities $serverCapabilities): self
return $this;
}
+ /**
+ * Enable one or more MCP protocol extensions, announced to clients under
+ * `capabilities.extensions` during the initialize handshake.
+ *
+ * Pass either fully qualified class names (instantiated with no arguments) or
+ * pre-built instances.
+ *
+ * @param class-string|ServerExtensionInterface ...$extensions
+ */
+ public function enableExtension(string|ServerExtensionInterface ...$extensions): self
+ {
+ foreach ($extensions as $extension) {
+ if (\is_string($extension)) {
+ if (!is_subclass_of($extension, ServerExtensionInterface::class)) {
+ throw new InvalidArgumentException(\sprintf('Extension class "%s" must implement "%s".', $extension, ServerExtensionInterface::class));
+ }
+ $extension = new $extension();
+ }
+
+ $this->extensions[$extension->getId()] = $extension->getCapabilities();
+ }
+
+ return $this;
+ }
+
/**
* Register a single custom method handler.
*
@@ -561,6 +592,7 @@ public function build(): Server
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
logging: true,
completions: true,
+ extensions: [] !== $this->extensions ? $this->extensions : null,
);
$serverInfo = $this->serverInfo ?? new Implementation();
diff --git a/tests/Inspector/Stdio/StdioMcpAppsTest.php b/tests/Inspector/Stdio/StdioMcpAppsTest.php
new file mode 100644
index 00000000..b8f2a6fc
--- /dev/null
+++ b/tests/Inspector/Stdio/StdioMcpAppsTest.php
@@ -0,0 +1,52 @@
+ [
+ 'method' => 'resources/read',
+ 'options' => [
+ 'uri' => 'ui://weather-app',
+ ],
+ 'testName' => 'read_weather_ui',
+ ],
+ 'Get Weather Tool Call (London)' => [
+ 'method' => 'tools/call',
+ 'options' => [
+ 'toolName' => 'get_weather',
+ 'toolArgs' => ['city' => 'London'],
+ ],
+ 'testName' => 'get_weather_london',
+ ],
+ 'Get Weather Tool Call (Tokyo)' => [
+ 'method' => 'tools/call',
+ 'options' => [
+ 'toolName' => 'get_weather',
+ 'toolArgs' => ['city' => 'Tokyo'],
+ ],
+ 'testName' => 'get_weather_tokyo',
+ ],
+ ];
+ }
+
+ protected function getServerScript(): string
+ {
+ return \dirname(__DIR__, 3).'/examples/server/mcp-apps/server.php';
+ }
+}
diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-prompts_list.json
new file mode 100644
index 00000000..7292222c
--- /dev/null
+++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-prompts_list.json
@@ -0,0 +1,3 @@
+{
+ "prompts": []
+}
diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_list.json
new file mode 100644
index 00000000..2ff24ba4
--- /dev/null
+++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_list.json
@@ -0,0 +1,13 @@
+{
+ "resources": [
+ {
+ "name": "weather-app",
+ "uri": "ui://weather-app",
+ "description": "Interactive weather dashboard",
+ "mimeType": "text/html;profile=mcp-app",
+ "_meta": {
+ "ui": {}
+ }
+ }
+ ]
+}
diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_read-read_weather_ui.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_read-read_weather_ui.json
new file mode 100644
index 00000000..16b23941
--- /dev/null
+++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_read-read_weather_ui.json
@@ -0,0 +1,22 @@
+{
+ "contents": [
+ {
+ "uri": "ui://weather-app",
+ "mimeType": "text/html;profile=mcp-app",
+ "_meta": {
+ "ui": {
+ "csp": {
+ "connectDomains": [
+ "https://api.weather.example.com"
+ ]
+ },
+ "permissions": {
+ "geolocation": {}
+ },
+ "prefersBorder": true
+ }
+ },
+ "text": "\n\n\n \n \n Weather Dashboard\n \n\n\n \n \n \n
\n \n \n\n\n"
+ }
+ ]
+}
diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_templates_list.json
new file mode 100644
index 00000000..e867d9d2
--- /dev/null
+++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-resources_templates_list.json
@@ -0,0 +1,3 @@
+{
+ "resourceTemplates": []
+}
diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_london.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_london.json
new file mode 100644
index 00000000..55cac881
--- /dev/null
+++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_london.json
@@ -0,0 +1,9 @@
+{
+ "content": [
+ {
+ "type": "text",
+ "text": "Weather in London: 15°C, Cloudy, Humidity: 78%"
+ }
+ ],
+ "isError": false
+}
diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_tokyo.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_tokyo.json
new file mode 100644
index 00000000..a79f5014
--- /dev/null
+++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_tokyo.json
@@ -0,0 +1,9 @@
+{
+ "content": [
+ {
+ "type": "text",
+ "text": "Weather in Tokyo: 22°C, Partly Cloudy, Humidity: 65%"
+ }
+ ],
+ "isError": false
+}
diff --git a/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_list.json
new file mode 100644
index 00000000..dcb064ab
--- /dev/null
+++ b/tests/Inspector/Stdio/snapshots/StdioMcpAppsTest-tools_list.json
@@ -0,0 +1,28 @@
+{
+ "tools": [
+ {
+ "name": "get_weather",
+ "description": "Get current weather for a city",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "city": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "city"
+ ]
+ },
+ "_meta": {
+ "ui": {
+ "resourceUri": "ui://weather-app",
+ "visibility": [
+ "model",
+ "app"
+ ]
+ }
+ }
+ }
+ ]
+}
diff --git a/tests/Unit/Schema/Extension/Apps/CapabilitiesExtensionsTest.php b/tests/Unit/Schema/Extension/Apps/CapabilitiesExtensionsTest.php
new file mode 100644
index 00000000..7e703912
--- /dev/null
+++ b/tests/Unit/Schema/Extension/Apps/CapabilitiesExtensionsTest.php
@@ -0,0 +1,201 @@
+ (new McpApps())->getCapabilities(),
+ ];
+
+ $caps = new ServerCapabilities(
+ tools: true,
+ resources: true,
+ extensions: $extensions,
+ );
+
+ $this->assertSame($extensions, $caps->extensions);
+ }
+
+ public function testServerCapabilitiesExtensionsDefaultNull(): void
+ {
+ $caps = new ServerCapabilities();
+
+ $this->assertNull($caps->extensions);
+ }
+
+ public function testServerCapabilitiesJsonSerializeWithExtensions(): void
+ {
+ $caps = new ServerCapabilities(
+ tools: true,
+ resources: true,
+ prompts: false,
+ extensions: [
+ McpApps::EXTENSION_ID => (new McpApps())->getCapabilities(),
+ ],
+ );
+
+ $json = $caps->jsonSerialize();
+
+ $this->assertArrayHasKey('extensions', $json);
+ $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $json['extensions']);
+ }
+
+ public function testServerCapabilitiesJsonSerializeWithoutExtensions(): void
+ {
+ $caps = new ServerCapabilities(tools: true);
+
+ $json = $caps->jsonSerialize();
+
+ $this->assertArrayNotHasKey('extensions', $json);
+ }
+
+ public function testServerCapabilitiesFromArrayWithExtensions(): void
+ {
+ $data = [
+ 'tools' => new \stdClass(),
+ 'extensions' => [
+ McpApps::EXTENSION_ID => ['mimeTypes' => ['text/html;profile=mcp-app']],
+ ],
+ ];
+
+ $caps = ServerCapabilities::fromArray($data);
+
+ $this->assertTrue($caps->tools);
+ $this->assertNotNull($caps->extensions);
+ $this->assertArrayHasKey(McpApps::EXTENSION_ID, $caps->extensions);
+ $this->assertSame(['text/html;profile=mcp-app'], $caps->extensions[McpApps::EXTENSION_ID]['mimeTypes']);
+ }
+
+ public function testServerCapabilitiesFromArrayWithoutExtensions(): void
+ {
+ $caps = ServerCapabilities::fromArray(['tools' => new \stdClass()]);
+
+ $this->assertNull($caps->extensions);
+ }
+
+ public function testClientCapabilitiesWithExtensions(): void
+ {
+ $extensions = [
+ McpApps::EXTENSION_ID => (new McpApps())->getCapabilities(),
+ ];
+
+ $caps = new ClientCapabilities(
+ extensions: $extensions,
+ );
+
+ $this->assertSame($extensions, $caps->extensions);
+ }
+
+ public function testClientCapabilitiesExtensionsDefaultNull(): void
+ {
+ $caps = new ClientCapabilities();
+
+ $this->assertNull($caps->extensions);
+ }
+
+ public function testClientCapabilitiesJsonSerializeWithExtensions(): void
+ {
+ $caps = new ClientCapabilities(
+ extensions: [
+ McpApps::EXTENSION_ID => (new McpApps())->getCapabilities(),
+ ],
+ );
+
+ $json = $caps->jsonSerialize();
+
+ $this->assertArrayHasKey('extensions', $json);
+ $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $json['extensions']);
+ }
+
+ public function testClientCapabilitiesJsonSerializeWithoutExtensions(): void
+ {
+ $caps = new ClientCapabilities();
+
+ $json = $caps->jsonSerialize();
+
+ // ClientCapabilities returns \stdClass when empty (so it serializes as `{}`, not `[]`).
+ if (\is_array($json)) {
+ $this->assertArrayNotHasKey('extensions', $json);
+ } else {
+ $this->assertObjectNotHasProperty('extensions', $json);
+ }
+ }
+
+ public function testClientCapabilitiesFromArrayWithExtensions(): void
+ {
+ $data = [
+ 'roots' => ['listChanged' => true],
+ 'extensions' => [
+ McpApps::EXTENSION_ID => ['mimeTypes' => ['text/html;profile=mcp-app']],
+ ],
+ ];
+
+ $caps = ClientCapabilities::fromArray($data);
+
+ $this->assertTrue($caps->roots);
+ $this->assertTrue($caps->rootsListChanged);
+ $this->assertNotNull($caps->extensions);
+ $this->assertArrayHasKey(McpApps::EXTENSION_ID, $caps->extensions);
+ }
+
+ public function testClientCapabilitiesFromArrayWithoutExtensions(): void
+ {
+ $caps = ClientCapabilities::fromArray(['roots' => ['listChanged' => true]]);
+
+ $this->assertNull($caps->extensions);
+ }
+
+ public function testBackwardCompatibilityServerCapabilities(): void
+ {
+ $caps = new ServerCapabilities(
+ tools: true,
+ toolsListChanged: false,
+ resources: true,
+ resourcesSubscribe: false,
+ resourcesListChanged: false,
+ prompts: true,
+ promptsListChanged: false,
+ logging: false,
+ completions: false,
+ experimental: null,
+ );
+
+ $this->assertNull($caps->extensions);
+
+ $json = $caps->jsonSerialize();
+ $this->assertArrayNotHasKey('extensions', $json);
+ }
+
+ public function testBackwardCompatibilityClientCapabilities(): void
+ {
+ $caps = new ClientCapabilities(
+ roots: true,
+ rootsListChanged: true,
+ sampling: true,
+ elicitation: true,
+ experimental: null,
+ );
+
+ $this->assertNull($caps->extensions);
+
+ $json = $caps->jsonSerialize();
+ $this->assertArrayNotHasKey('extensions', $json);
+ }
+}
diff --git a/tests/Unit/Schema/Extension/Apps/McpAppsTest.php b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php
new file mode 100644
index 00000000..758d4272
--- /dev/null
+++ b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php
@@ -0,0 +1,204 @@
+assertSame('io.modelcontextprotocol/ui', $extension->getId());
+ $this->assertSame(['mimeTypes' => ['text/html;profile=mcp-app']], $extension->getCapabilities());
+ }
+
+ public function testUiResourceCspSerialization(): void
+ {
+ $csp = new UiResourceCsp(
+ connectDomains: ['https://api.example.com'],
+ resourceDomains: ['https://cdn.example.com'],
+ frameDomains: ['https://embed.example.com'],
+ baseUriDomains: ['https://example.com'],
+ );
+
+ $serialized = $csp->jsonSerialize();
+
+ $this->assertSame(['https://api.example.com'], $serialized['connectDomains']);
+ $this->assertSame(['https://cdn.example.com'], $serialized['resourceDomains']);
+ $this->assertSame(['https://embed.example.com'], $serialized['frameDomains']);
+ $this->assertSame(['https://example.com'], $serialized['baseUriDomains']);
+ }
+
+ public function testUiResourceCspOmitsNullFields(): void
+ {
+ $csp = new UiResourceCsp(connectDomains: ['https://api.example.com']);
+
+ $serialized = $csp->jsonSerialize();
+
+ $this->assertArrayHasKey('connectDomains', $serialized);
+ $this->assertArrayNotHasKey('resourceDomains', $serialized);
+ $this->assertArrayNotHasKey('frameDomains', $serialized);
+ $this->assertArrayNotHasKey('baseUriDomains', $serialized);
+ }
+
+ public function testUiResourceCspFromArray(): void
+ {
+ $csp = UiResourceCsp::fromArray([
+ 'connectDomains' => ['https://api.example.com'],
+ 'frameDomains' => ['https://embed.example.com'],
+ ]);
+
+ $this->assertSame(['https://api.example.com'], $csp->connectDomains);
+ $this->assertNull($csp->resourceDomains);
+ $this->assertSame(['https://embed.example.com'], $csp->frameDomains);
+ $this->assertNull($csp->baseUriDomains);
+ }
+
+ public function testUiResourcePermissionsSerialization(): void
+ {
+ $perms = new UiResourcePermissions(
+ camera: true,
+ microphone: false,
+ geolocation: true,
+ clipboardWrite: false,
+ );
+
+ $serialized = $perms->jsonSerialize();
+
+ // Per spec, each requested permission is an empty object marker.
+ $this->assertEquals(new \stdClass(), $serialized['camera']);
+ $this->assertArrayNotHasKey('microphone', $serialized);
+ $this->assertEquals(new \stdClass(), $serialized['geolocation']);
+ $this->assertArrayNotHasKey('clipboardWrite', $serialized);
+ $this->assertSame('{"camera":{},"geolocation":{}}', json_encode($perms));
+ }
+
+ public function testUiResourcePermissionsOmitsUnrequestedFields(): void
+ {
+ $perms = new UiResourcePermissions(clipboardWrite: true);
+
+ $serialized = $perms->jsonSerialize();
+
+ $this->assertArrayNotHasKey('camera', $serialized);
+ $this->assertArrayNotHasKey('microphone', $serialized);
+ $this->assertArrayNotHasKey('geolocation', $serialized);
+ $this->assertArrayHasKey('clipboardWrite', $serialized);
+ }
+
+ public function testUiResourcePermissionsFromArray(): void
+ {
+ // Spec wire shape: presence indicates a request; the value is an empty object.
+ $perms = UiResourcePermissions::fromArray([
+ 'camera' => [],
+ 'clipboardWrite' => [],
+ ]);
+
+ $this->assertTrue($perms->camera);
+ $this->assertFalse($perms->microphone);
+ $this->assertFalse($perms->geolocation);
+ $this->assertTrue($perms->clipboardWrite);
+ }
+
+ public function testUiResourceContentMetaSerialization(): void
+ {
+ $meta = new UiResourceContentMeta(
+ csp: new UiResourceCsp(connectDomains: ['https://api.example.com']),
+ permissions: new UiResourcePermissions(clipboardWrite: true),
+ domain: 'example.com',
+ prefersBorder: true,
+ );
+
+ $serialized = $meta->jsonSerialize();
+
+ $this->assertArrayHasKey('csp', $serialized);
+ $this->assertArrayHasKey('permissions', $serialized);
+ $this->assertSame('example.com', $serialized['domain']);
+ $this->assertTrue($serialized['prefersBorder']);
+ }
+
+ public function testUiResourceContentMetaOmitsNullFields(): void
+ {
+ $meta = new UiResourceContentMeta(prefersBorder: true);
+
+ $serialized = $meta->jsonSerialize();
+
+ $this->assertArrayNotHasKey('csp', $serialized);
+ $this->assertArrayNotHasKey('permissions', $serialized);
+ $this->assertArrayNotHasKey('domain', $serialized);
+ $this->assertArrayHasKey('prefersBorder', $serialized);
+ }
+
+ public function testUiResourceContentMetaFromArray(): void
+ {
+ $meta = UiResourceContentMeta::fromArray([
+ 'csp' => ['connectDomains' => ['https://api.example.com']],
+ 'permissions' => ['clipboardWrite' => []],
+ 'domain' => 'example.com',
+ 'prefersBorder' => false,
+ ]);
+
+ $this->assertInstanceOf(UiResourceCsp::class, $meta->csp);
+ $this->assertSame(['https://api.example.com'], $meta->csp->connectDomains);
+ $this->assertInstanceOf(UiResourcePermissions::class, $meta->permissions);
+ $this->assertTrue($meta->permissions->clipboardWrite);
+ $this->assertSame('example.com', $meta->domain);
+ $this->assertFalse($meta->prefersBorder);
+ }
+
+ public function testUiToolMetaSerialization(): void
+ {
+ $meta = new UiToolMeta(
+ resourceUri: 'ui://my-app',
+ visibility: [ToolVisibility::Model->value, ToolVisibility::App->value],
+ );
+
+ $serialized = $meta->jsonSerialize();
+
+ $this->assertSame('ui://my-app', $serialized['resourceUri']);
+ $this->assertSame(['model', 'app'], $serialized['visibility']);
+ }
+
+ public function testUiToolMetaOmitsNullFields(): void
+ {
+ $meta = new UiToolMeta(resourceUri: 'ui://my-app');
+
+ $serialized = $meta->jsonSerialize();
+
+ $this->assertArrayHasKey('resourceUri', $serialized);
+ $this->assertArrayNotHasKey('visibility', $serialized);
+ }
+
+ public function testUiToolMetaFromArray(): void
+ {
+ $meta = UiToolMeta::fromArray([
+ 'resourceUri' => 'ui://my-app',
+ 'visibility' => ['app'],
+ ]);
+
+ $this->assertSame('ui://my-app', $meta->resourceUri);
+ $this->assertSame(['app'], $meta->visibility);
+ }
+
+ public function testToolVisibilityEnum(): void
+ {
+ $this->assertSame('model', ToolVisibility::Model->value);
+ $this->assertSame('app', ToolVisibility::App->value);
+ }
+}
diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php
index 8a92d599..96444f78 100644
--- a/tests/Unit/Server/BuilderTest.php
+++ b/tests/Unit/Server/BuilderTest.php
@@ -14,10 +14,12 @@
use Mcp\Capability\Registry\ElementReference;
use Mcp\Capability\Registry\ReferenceHandlerInterface;
use Mcp\Schema\Content\TextContent;
+use Mcp\Schema\Extension\Apps\McpApps;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Server;
use Mcp\Server\Handler\Request\CallToolHandler;
+use Mcp\Server\Handler\Request\InitializeHandler;
use Mcp\Server\Session\SessionInterface;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
@@ -79,6 +81,48 @@ public function testCustomReferenceHandlerIsUsedForToolCalls(): void
$this->assertSame('intercepted', $result);
}
+ #[TestDox('enableExtension() registers an extension by class name')]
+ public function testEnableExtensionByClassName(): void
+ {
+ $server = Server::builder()
+ ->setServerInfo('test', '1.0.0')
+ ->enableExtension(McpApps::class)
+ ->build();
+
+ $capabilities = $this->extractServerCapabilities($server);
+
+ $this->assertNotNull($capabilities->extensions);
+ $this->assertArrayHasKey(McpApps::EXTENSION_ID, $capabilities->extensions);
+ $this->assertSame(['mimeTypes' => [McpApps::MIME_TYPE]], $capabilities->extensions[McpApps::EXTENSION_ID]);
+ }
+
+ #[TestDox('enableExtension() registers an extension by instance')]
+ public function testEnableExtensionByInstance(): void
+ {
+ $server = Server::builder()
+ ->setServerInfo('test', '1.0.0')
+ ->enableExtension(new McpApps())
+ ->build();
+
+ $capabilities = $this->extractServerCapabilities($server);
+
+ $this->assertArrayHasKey(McpApps::EXTENSION_ID, $capabilities->extensions ?? []);
+ }
+
+ private function extractServerCapabilities(Server $server): \Mcp\Schema\ServerCapabilities
+ {
+ $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server);
+ $requestHandlers = (new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol);
+
+ foreach ($requestHandlers as $handler) {
+ if ($handler instanceof InitializeHandler) {
+ return $handler->configuration->capabilities;
+ }
+ }
+
+ $this->fail('InitializeHandler not found in request handlers');
+ }
+
private function callTool(Server $server, string $toolName): mixed
{
$protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server);