diff --git a/examples/server/mcp-apps/WeatherApp.php b/examples/server/mcp-apps/WeatherApp.php
new file mode 100644
index 00000000..f297d5df
--- /dev/null
+++ b/examples/server/mcp-apps/WeatherApp.php
@@ -0,0 +1,194 @@
+
+
+
+
+
+ Weather Dashboard
+
+
+
+
+
Weather Dashboard
+
+
+
+
+
+
+
+
+
+ HTML;
+
+ $contentMeta = new UiResourceContentMeta(
+ csp: new UiResourceCsp(
+ connectDomains: ['https://api.weather.example.com'],
+ ),
+ permissions: new UiResourcePermissions(
+ geolocation: true,
+ ),
+ prefersBorder: true,
+ );
+
+ return new TextResourceContents(
+ uri: 'ui://weather-app',
+ mimeType: McpApps::MIME_TYPE,
+ text: $html,
+ meta: $contentMeta->toMetaArray(),
+ );
+ }
+
+ /**
+ * Returns weather data for a given city.
+ *
+ * This tool is linked to the ui://weather-app UI resource via _meta.ui.resourceUri,
+ * making it callable by both the LLM agent and the rendered HTML app.
+ *
+ * @return string simulated weather data
+ */
+ public function getWeather(string $city): string
+ {
+ // In a real application, this would call an external weather API.
+ $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%'],
+ ];
+
+ $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..cc689a60
--- /dev/null
+++ b/examples/server/mcp-apps/server.php
@@ -0,0 +1,92 @@
+#!/usr/bin/env php
+info('Starting MCP Apps Example Server...');
+
+// Build the tool UI metadata using the typed helper class.
+// This links the "get_weather" tool to the "ui://weather-app" UI resource.
+$toolUiMeta = new UiToolMeta(
+ resourceUri: 'ui://weather-app',
+ visibility: [ToolVisibility::Model->value, ToolVisibility::App->value],
+);
+
+$server = Server::builder()
+ ->setServerInfo('MCP Apps Weather Example', '1.0.0')
+ ->setLogger(logger())
+ ->setContainer(container())
+
+ // Register the UI resource with ui:// scheme and MCP App MIME type.
+ // The _meta marks this as a UI resource in the resources/list response.
+ ->addResource(
+ [WeatherApp::class, 'getWeatherApp'],
+ 'ui://weather-app',
+ 'weather-app',
+ description: 'Interactive weather dashboard',
+ mimeType: McpApps::MIME_TYPE,
+ meta: ['ui' => []],
+ )
+
+ // Register the tool linked to the UI resource via _meta.ui.
+ ->addTool(
+ [WeatherApp::class, 'getWeather'],
+ 'get_weather',
+ description: 'Get current weather for a city',
+ meta: $toolUiMeta->toMetaArray(),
+ )
+
+ // Advertise MCP Apps support in server capabilities.
+ ->setCapabilities(new ServerCapabilities(
+ tools: true,
+ resources: true,
+ prompts: false,
+ extensions: [
+ McpApps::EXTENSION_ID => McpApps::extensionCapability(),
+ ],
+ ))
+ ->build();
+
+/*
+ * Equivalent attribute-based registration (PHP attributes require constant expressions,
+ * so _meta must be specified as a raw array literal):
+ *
+ * #[McpResource(
+ * uri: 'ui://weather-app',
+ * name: 'weather-app',
+ * description: 'Interactive weather dashboard',
+ * mimeType: 'text/html;profile=mcp-app',
+ * meta: ['ui' => []],
+ * )]
+ * public function getWeatherApp(): TextResourceContents { ... }
+ *
+ * #[McpTool(
+ * name: 'get_weather',
+ * description: 'Get current weather for a city',
+ * meta: ['ui' => ['resourceUri' => 'ui://weather-app', 'visibility' => ['model', 'app']]],
+ * )]
+ * public function getWeather(string $city): string { ... }
+ */
+
+$result = $server->run(transport());
+
+logger()->info('Server stopped gracefully.', ['result' => $result]);
+
+shutdown($result);
diff --git a/src/Schema/ClientCapabilities.php b/src/Schema/ClientCapabilities.php
index f6f277a0..0bbac16e 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,
* }
*/
public function jsonSerialize(): array
@@ -102,6 +107,10 @@ public function jsonSerialize(): array
$data['experimental'] = (object) $this->experimental;
}
+ if ($this->extensions) {
+ $data['extensions'] = (object) $this->extensions;
+ }
+
return $data;
}
}
diff --git a/src/Schema/Enum/ToolVisibility.php b/src/Schema/Enum/ToolVisibility.php
new file mode 100644
index 00000000..2f57da18
--- /dev/null
+++ b/src/Schema/Enum/ToolVisibility.php
@@ -0,0 +1,26 @@
+ McpApps::extensionCapability(),
+ * ])
+ *
+ * @return array{mimeTypes: string[]}
+ */
+ public static function extensionCapability(): array
+ {
+ return [
+ 'mimeTypes' => [self::MIME_TYPE],
+ ];
+ }
+
+ /**
+ * Checks whether a Resource is a UI resource based on its URI scheme and MIME type.
+ */
+ public static function isUiResource(Resource $resource): bool
+ {
+ return str_starts_with($resource->uri, self::URI_SCHEME.'://')
+ && self::MIME_TYPE === $resource->mimeType;
+ }
+}
diff --git a/src/Schema/Extension/Apps/UiResourceContentMeta.php b/src/Schema/Extension/Apps/UiResourceContentMeta.php
new file mode 100644
index 00000000..8e98e932
--- /dev/null
+++ b/src/Schema/Extension/Apps/UiResourceContentMeta.php
@@ -0,0 +1,93 @@
+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;
+ }
+
+ /**
+ * Returns an array suitable for use as the _meta parameter on resource contents.
+ *
+ * Usage:
+ *
+ * new TextResourceContents(
+ * uri: 'ui://my-app',
+ * mimeType: McpApps::MIME_TYPE,
+ * text: $html,
+ * meta: $contentMeta->toMetaArray(),
+ * )
+ *
+ * @return array{ui: UiResourceContentMetaData}
+ */
+ public function toMetaArray(): array
+ {
+ return ['ui' => $this->jsonSerialize()];
+ }
+}
diff --git a/src/Schema/Extension/Apps/UiResourceCsp.php b/src/Schema/Extension/Apps/UiResourceCsp.php
new file mode 100644
index 00000000..257a2996
--- /dev/null
+++ b/src/Schema/Extension/Apps/UiResourceCsp.php
@@ -0,0 +1,78 @@
+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..367973da
--- /dev/null
+++ b/src/Schema/Extension/Apps/UiResourcePermissions.php
@@ -0,0 +1,69 @@
+camera) {
+ $data['camera'] = $this->camera;
+ }
+ if (null !== $this->microphone) {
+ $data['microphone'] = $this->microphone;
+ }
+ if (null !== $this->geolocation) {
+ $data['geolocation'] = $this->geolocation;
+ }
+ if (null !== $this->clipboardWrite) {
+ $data['clipboardWrite'] = $this->clipboardWrite;
+ }
+
+ return $data;
+ }
+}
diff --git a/src/Schema/Extension/Apps/UiToolMeta.php b/src/Schema/Extension/Apps/UiToolMeta.php
new file mode 100644
index 00000000..1464dd6b
--- /dev/null
+++ b/src/Schema/Extension/Apps/UiToolMeta.php
@@ -0,0 +1,79 @@
+resourceUri) {
+ $data['resourceUri'] = $this->resourceUri;
+ }
+ if (null !== $this->visibility) {
+ $data['visibility'] = $this->visibility;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Returns an array suitable for use as the _meta parameter on a Tool.
+ *
+ * Usage with explicit registration:
+ *
+ * ->addTool($handler, 'my_tool', meta: $uiToolMeta->toMetaArray())
+ *
+ * Usage with attributes (raw array, since attributes require constant expressions):
+ *
+ * #[McpTool(meta: ['ui' => ['resourceUri' => 'ui://my-app', 'visibility' => ['model', 'app']]])]
+ *
+ * @return array{ui: UiToolMetaData}
+ */
+ public function toMetaArray(): array
+ {
+ return ['ui' => $this->jsonSerialize()];
+ }
+}
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/tests/Unit/Schema/Extension/Apps/CapabilitiesExtensionsTest.php b/tests/Unit/Schema/Extension/Apps/CapabilitiesExtensionsTest.php
new file mode 100644
index 00000000..bec9d669
--- /dev/null
+++ b/tests/Unit/Schema/Extension/Apps/CapabilitiesExtensionsTest.php
@@ -0,0 +1,196 @@
+ McpApps::extensionCapability(),
+ ];
+
+ $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 => McpApps::extensionCapability(),
+ ],
+ );
+
+ $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 => McpApps::extensionCapability(),
+ ];
+
+ $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 => McpApps::extensionCapability(),
+ ],
+ );
+
+ $json = $caps->jsonSerialize();
+
+ $this->assertArrayHasKey('extensions', $json);
+ $this->assertObjectHasProperty(McpApps::EXTENSION_ID, $json['extensions']);
+ }
+
+ public function testClientCapabilitiesJsonSerializeWithoutExtensions(): void
+ {
+ $caps = new ClientCapabilities();
+
+ $json = $caps->jsonSerialize();
+
+ $this->assertArrayNotHasKey('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..6f899524
--- /dev/null
+++ b/tests/Unit/Schema/Extension/Apps/McpAppsTest.php
@@ -0,0 +1,269 @@
+assertSame('io.modelcontextprotocol/ui', McpApps::EXTENSION_ID);
+ $this->assertSame('text/html;profile=mcp-app', McpApps::MIME_TYPE);
+ $this->assertSame('ui', McpApps::URI_SCHEME);
+ }
+
+ public function testExtensionCapability(): void
+ {
+ $capability = McpApps::extensionCapability();
+
+ $this->assertSame(['mimeTypes' => ['text/html;profile=mcp-app']], $capability);
+ }
+
+ public function testIsUiResourceReturnsTrueForUiResource(): void
+ {
+ $resource = new Resource(
+ uri: 'ui://my-app',
+ name: 'my-app',
+ mimeType: McpApps::MIME_TYPE,
+ );
+
+ $this->assertTrue(McpApps::isUiResource($resource));
+ }
+
+ public function testIsUiResourceReturnsFalseForNonUiScheme(): void
+ {
+ $resource = new Resource(
+ uri: 'file://my-app',
+ name: 'my-app',
+ mimeType: McpApps::MIME_TYPE,
+ );
+
+ $this->assertFalse(McpApps::isUiResource($resource));
+ }
+
+ public function testIsUiResourceReturnsFalseForNonUiMimeType(): void
+ {
+ $resource = new Resource(
+ uri: 'ui://my-app',
+ name: 'my-app',
+ mimeType: 'text/html',
+ );
+
+ $this->assertFalse(McpApps::isUiResource($resource));
+ }
+
+ 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();
+
+ $this->assertTrue($serialized['camera']);
+ $this->assertFalse($serialized['microphone']);
+ $this->assertTrue($serialized['geolocation']);
+ $this->assertFalse($serialized['clipboardWrite']);
+ }
+
+ public function testUiResourcePermissionsOmitsNullFields(): 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
+ {
+ $perms = UiResourcePermissions::fromArray([
+ 'camera' => true,
+ 'clipboardWrite' => false,
+ ]);
+
+ $this->assertTrue($perms->camera);
+ $this->assertNull($perms->microphone);
+ $this->assertNull($perms->geolocation);
+ $this->assertFalse($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' => true],
+ '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 testUiResourceContentMetaToMetaArray(): void
+ {
+ $meta = new UiResourceContentMeta(
+ csp: new UiResourceCsp(connectDomains: ['https://api.example.com']),
+ prefersBorder: true,
+ );
+
+ $metaArray = $meta->toMetaArray();
+
+ $this->assertArrayHasKey('ui', $metaArray);
+ $this->assertArrayHasKey('csp', $metaArray['ui']);
+ $this->assertArrayHasKey('prefersBorder', $metaArray['ui']);
+ }
+
+ 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 testUiToolMetaToMetaArray(): void
+ {
+ $meta = new UiToolMeta(
+ resourceUri: 'ui://dashboard',
+ visibility: ['model', 'app'],
+ );
+
+ $metaArray = $meta->toMetaArray();
+
+ $this->assertArrayHasKey('ui', $metaArray);
+ $this->assertSame('ui://dashboard', $metaArray['ui']['resourceUri']);
+ $this->assertSame(['model', 'app'], $metaArray['ui']['visibility']);
+ }
+
+ public function testToolVisibilityEnum(): void
+ {
+ $this->assertSame('model', ToolVisibility::Model->value);
+ $this->assertSame('app', ToolVisibility::App->value);
+ }
+}