From 9c9b41b43e876da3f1640a3373a364320c5d482d Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Sat, 11 Apr 2026 18:01:08 +0200 Subject: [PATCH] feat: add MCP Apps extension (io.modelcontextprotocol/ui) support Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/server/mcp-apps/WeatherApp.php | 194 +++++++++++++ examples/server/mcp-apps/server.php | 92 ++++++ src/Schema/ClientCapabilities.php | 13 +- src/Schema/Enum/ToolVisibility.php | 26 ++ src/Schema/Extension/Apps/McpApps.php | 67 +++++ .../Extension/Apps/UiResourceContentMeta.php | 93 ++++++ src/Schema/Extension/Apps/UiResourceCsp.php | 78 +++++ .../Extension/Apps/UiResourcePermissions.php | 69 +++++ src/Schema/Extension/Apps/UiToolMeta.php | 79 +++++ src/Schema/ServerCapabilities.php | 9 + .../Apps/CapabilitiesExtensionsTest.php | 196 +++++++++++++ .../Schema/Extension/Apps/McpAppsTest.php | 269 ++++++++++++++++++ 12 files changed, 1183 insertions(+), 2 deletions(-) create mode 100644 examples/server/mcp-apps/WeatherApp.php create mode 100644 examples/server/mcp-apps/server.php create mode 100644 src/Schema/Enum/ToolVisibility.php create mode 100644 src/Schema/Extension/Apps/McpApps.php create mode 100644 src/Schema/Extension/Apps/UiResourceContentMeta.php create mode 100644 src/Schema/Extension/Apps/UiResourceCsp.php create mode 100644 src/Schema/Extension/Apps/UiResourcePermissions.php create mode 100644 src/Schema/Extension/Apps/UiToolMeta.php create mode 100644 tests/Unit/Schema/Extension/Apps/CapabilitiesExtensionsTest.php create mode 100644 tests/Unit/Schema/Extension/Apps/McpAppsTest.php 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); + } +}