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 + + + +
+ + +
+
+
+
+
+
+
+
🌤️
+
+
+
+ Humidity — +
+
+
+ + + 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
\n
\n
🌤️
\n
\n
\n
\n Humidity —\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);