Skip to content

Apps

The client half of the MCP Apps extension (io.modelcontextprotocol/ui, SEP-1865) pairs two classes. AppsClientExtension advertises the renderable mime types. The AppClient facade reads the _meta.ui metadata and verifies ui:// reads:

use Nexus\Mcp\Client\ClientBuilder;
use Nexus\Mcp\Extension\Apps\Client\AppClient;
use Nexus\Mcp\Extension\Apps\Client\AppsClientExtension;

$client = (new ClientBuilder())
    ->setClientInfo('demo', '1.0.0')
    ->enableExtension(new AppsClientExtension())
    ->build();

$client->connect($transport);
$client->discover();

$apps = new AppClient($client);

Enabling the extension stamps {"mimeTypes": ["text/html;profile=mcp-app"]} under io.modelcontextprotocol/ui in the _meta capabilities envelope on every request. That is the declaration a server consults before it exposes its UI-enabled tools.

The spec makes mimeTypes required, so the constructor rejects an empty list. A host that renders more than the default profile passes the same list to both halves, so the declaration and the verification stay one decision: new AppsClientExtension(mimeTypes: $types) and new AppClient($client, mimeTypes: $types). The extension declares no methods of its own, so nothing new is gated or dispatched.

Finding UI-enabled tools

resolveToolMeta() decodes a tool's _meta.ui object into a typed UiToolMeta. findAppTools() filters a listing down to the tools that link a view, each paired with the metadata the filter already resolved:

$tools = $client->listTools();

foreach ($apps->findAppTools($tools) as $appTool) {
    // $appTool->tool, $appTool->uiMeta->resourceUri, $appTool->uiMeta->visibility
}

The filter skips a tool whose _meta.ui cannot be decoded rather than abort the listing, since the unprefixed ui meta key is peer-controlled data. resolveToolMeta() on a single tool stays strict and throws on malformed metadata.

The reader tolerates the deprecated flat _meta["ui/resourceUri"] key as a fallback when the nested form is absent or carries no resourceUri. Servers that predate the nested shape, or migrated it partially, still resolve. The SDK itself never emits the deprecated key.

Reading the view

readAppResource() wraps Client::readResource() for ui:// URIs. It rejects any other scheme up front. It verifies that every returned content item carries one of the accepted mime types, which are the mimeTypes the facade was constructed with, defaulting to text/html;profile=mcp-app. It throws RuntimeException when the server drifts. An InputRequiredResult passes through untouched, like any other input-required flow:

use Nexus\Mcp\Core\Schema\Result\ReadResourceResult;

$read = $apps->readAppResource('ui://weather-server/dashboard');

if ($read instanceof ReadResourceResult) {
    $meta = $apps->resolveResourceMeta($read->contents[0]);
    // $meta?->csp, $meta?->permissions, $meta?->domain, $meta?->prefersBorder
}

resolveResourceMeta() accepts both a resources/list descriptor and a read content item, since the spec carries the same UiResourceMeta shape in both positions.

Rendering is out of the SDK's scope. Hosting the HTML in a sandboxed iframe, enforcing the declared CSP, and speaking the ui/* postMessage protocol to the embedded view are the browser host's job. The upstream modelcontextprotocol/ext-apps project documents them.

The server half is documented in Server apps, and examples/apps-e2e/ runs the whole flow, browser host included.