[Schema][Server] Add MCP Apps extension (io.modelcontextprotocol/ui) support#281
[Schema][Server] Add MCP Apps extension (io.modelcontextprotocol/ui) support#281chr-hertel wants to merge 1 commit into
Conversation
9c9b41b to
d9ca7e6
Compare
d9ca7e6 to
ea82312
Compare
1f1c1c6 to
f1a76a6
Compare
2039755 to
cf9ee8e
Compare
|
This could use some love as follow up when it comes to the Builder. maybe a |
Adds support for the MCP Apps protocol extension, which lets servers expose interactive HTML UIs as resources rendered by spec-conforming clients (e.g. Goose) in sandboxed iframes. - Generic extension plumbing: ServerExtensionInterface and Builder::enableExtension(), with extensions advertised under capabilities.extensions during the initialize handshake. ClientCapabilities/ServerCapabilities gain an extensions field. - Schema DTOs under Mcp\Schema\Extension\Apps: the McpApps marker, UiToolMeta, ToolVisibility enum, UiResourceContentMeta, UiResourceCsp, and UiResourcePermissions (with empty-object presence markers per spec). - A worked weather example under examples/server/mcp-apps/ with a minimal spec-conforming view (ui/initialize handshake, postMessage JSONRPCMessage objects, and ResizeObserver-driven size reporting). - docs/extensions.md guide with pointers from index.md and examples.md to the ext-apps repository for the TypeScript SDK and richer view-side patterns. - Unit and inspector snapshot test coverage. Spec: https://github.com/modelcontextprotocol/ext-apps
cf9ee8e to
fda2228
Compare
| * @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, | ||
| ); | ||
| } |
There was a problem hiding this comment.
🟡 risk — jsonSerialize() return type annotation UiResourceContentMetaData claims csp?: UiResourceCspData (array shape) but the property stores a UiResourceCsp object. PHPStan-level lie; serialization still works via recursive JsonSerializable.
Fix: annotate as array{csp?: UiResourceCsp, permissions?: UiResourcePermissions, domain?: string, prefersBorder?: bool}, or call ->jsonSerialize() explicitly on each.
| * | ||
| * @author Christopher Hertel <mail@christopher-hertel.de> | ||
| */ | ||
| final class UiResourcePermissions implements \JsonSerializable |
There was a problem hiding this comment.
🟡 risk — Permission set hardcoded to 4 entries (camera/microphone/geolocation/clipboardWrite). If the spec adds new permissions, fromArray() silently drops them and jsonSerialize() cannot emit them.
Fix: keep unknown keys as passthrough (array<string, \stdClass>) or document the closed set explicitly.
| 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), | ||
| ); | ||
| } |
There was a problem hiding this comment.
🔵 nit — \array_key_exists('camera', $data) treats 'camera' => null as requested. Spec payload sends 'camera' => {}.
Fix: tighten to isset(), or accept either form and document the chosen contract.
| */ | ||
| public function __construct( | ||
| public readonly ?string $resourceUri = null, | ||
| public readonly ?array $visibility = null, |
There was a problem hiding this comment.
🟡 risk — ?string[] $visibility forces callers to pass [ToolVisibility::Model->value, ...]. Verbose, typo-prone, defeats the existing ToolVisibility enum.
Fix: accept array<ToolVisibility>|array<string> and normalize in the constructor — catches typos at construction time.
| final class McpApps implements ServerExtensionInterface | ||
| { | ||
| public const EXTENSION_ID = 'io.modelcontextprotocol/ui'; | ||
| public const MIME_TYPE = 'text/html;profile=mcp-app'; |
There was a problem hiding this comment.
❓ question — MIME constant text/html;profile=mcp-app. RFC 6838 typically quotes the profile parameter (profile="mcp-app"). Is the unquoted form spec-mandated by io.modelcontextprotocol/ui, or just copied loosely?
| 'weather-app', | ||
| description: 'Interactive weather dashboard', | ||
| mimeType: McpApps::MIME_TYPE, | ||
| meta: ['ui' => new stdClass()], |
There was a problem hiding this comment.
🔵 nit — meta: ['ui' => new stdClass()] is a magic presence marker. Future readers will not know why an empty stdClass is required vs. just omitting the key.
Fix: introduce a typed marker (e.g. McpApps::resourceMarker(): \stdClass) so intent is discoverable and the empty-object protocol detail is hidden behind a named API.
| let initialized = false; | ||
| const pending = new Map(); | ||
|
|
||
| function post(msg) { window.parent.postMessage(msg, '*'); } |
There was a problem hiding this comment.
🔵 nit — post(msg) uses '*' as postMessage target origin. That's a spec-level limitation (the iframe doesn't know its parent origin upfront), but worth an inline comment here — the receiving host must validate sender origin on its side.
| clientInfo: { name: 'weather-app', version: '1.0.0' }, | ||
| }); | ||
| } catch { /* degrade gracefully on non-conforming hosts */ } | ||
| initialized = true; |
There was a problem hiding this comment.
🔵 nit — initialized = true is set but never read anywhere in the file. Dead variable.
| ), | ||
| 'ui://my-app', | ||
| mimeType: McpApps::MIME_TYPE, | ||
| meta: ['ui' => new \stdClass()], |
There was a problem hiding this comment.
🔵 nit — The example uses meta: ['ui' => new \stdClass()] on the resource and meta: ['ui' => new UiResourceContentMeta(...)] inside TextResourceContents a few lines up. Two different _meta.ui shapes (presence marker vs. structured metadata) — readers will conflate them.
Fix: add one sentence clarifying when each shape applies.
| 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; | ||
| } | ||
|
|
There was a problem hiding this comment.
🔵 nit — Empty array [] for any of the *Domains fields serializes to "connectDomains": [] etc. Spec question: is an empty list "allow none" or "omit field"? If "omit", change the guards to if ($this->connectDomains) (truthy check) instead of null !== ....
Summary
Mcp\Schema\Extension\Apps\for the MCP Apps extension (io.modelcontextprotocol/ui, spec2026-01-26)extensionsfield to bothServerCapabilitiesandClientCapabilitiesfor extension negotiationToolVisibilityenum (model/app)examples/server/mcp-apps/demonstrating UI resources and tool-UI linkageDetails
The MCP Apps extension enables servers to expose interactive HTML UI applications as MCP resources. This PR adds server-side support:
McpApps(constants/helpers),UiResourceCsp,UiResourcePermissions,UiResourceContentMeta,UiToolMeta— all withfromArray()/jsonSerialize()andtoMetaArray()helpers for embedding in_metaextensionsparameter on both capability classes (backward-compatible, defaults tonull)resources/list,resources/read,tools/list,tools/callui/initialize,ui/open-link, etc.) which runs between the rendered HTML and the host applicationTest plan
make cs— coding standards passmake phpstan— static analysis passes (0 errors)make unit-tests— all 754 tests pass (34 new)npx @modelcontextprotocol/inspector php examples/server/mcp-apps/server.phpRunning in Goose
Screencast.from.2026-05-11.23-45-55.webm