941 lines
27 KiB
Markdown
941 lines
27 KiB
Markdown
|
|
# MCP UI Apps Reference
|
||
|
|
|
||
|
|
## Quick Start
|
||
|
|
|
||
|
|
`make:mcp-app-resource DashboardApp` generates two files — a PHP registration stub and a Blade view. The entire app lives in the Blade view.
|
||
|
|
|
||
|
|
**PHP class** — renders the Blade view. The view name is auto-inferred from the class name (`mcp.<kebab-class-name>`), so the generated stub needs no changes unless you're passing additional server-side data:
|
||
|
|
|
||
|
|
```php
|
||
|
|
class DashboardApp extends AppResource
|
||
|
|
{
|
||
|
|
public function handle(Request $request): Response
|
||
|
|
{
|
||
|
|
return Response::view('mcp.dashboard-app', [
|
||
|
|
'title' => $this->title(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Blade view** — HTML structure + inline JS, everything in one file:
|
||
|
|
|
||
|
|
```blade
|
||
|
|
<x-mcp::app title="Dashboard App">
|
||
|
|
<x-slot:head>
|
||
|
|
<script type="module">
|
||
|
|
createMcpApp(async (app) => {
|
||
|
|
document.getElementById('run-btn').addEventListener('click', async () => {
|
||
|
|
const result = await app.callServerTool({ name: 'tool-name', arguments: {} });
|
||
|
|
document.getElementById('output').textContent = result.content[0]?.text ?? '';
|
||
|
|
});
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
</x-slot:head>
|
||
|
|
|
||
|
|
<div id="app">
|
||
|
|
<h1>Dashboard App</h1>
|
||
|
|
<button id="run-btn">Run</button>
|
||
|
|
<p id="output"></p>
|
||
|
|
</div>
|
||
|
|
</x-mcp::app>
|
||
|
|
```
|
||
|
|
|
||
|
|
`createMcpApp` is a global pre-bundled by the package — no npm install, no imports, no Vite required. It handles connection, error handling, and host theming automatically.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Core Concept: Tool + Resource
|
||
|
|
|
||
|
|
Every MCP App is built from two parts linked together:
|
||
|
|
|
||
|
|
- **Tool** — called by the LLM or host. Returns a text/data response and tells the host which UI resource to render via `_meta.ui.resourceUri`.
|
||
|
|
- **AppResource** — serves the self-contained HTML app. The host fetches it after the tool is called and renders it in a sandboxed iframe.
|
||
|
|
|
||
|
|
```
|
||
|
|
LLM calls Tool
|
||
|
|
└─► Tool response includes _meta.ui.resourceUri → "ui://dashboard-app"
|
||
|
|
└─► Host fetches AppResource at that URI
|
||
|
|
└─► Host renders HTML in sandboxed iframe
|
||
|
|
└─► createMcpApp() connects the iframe back to the server
|
||
|
|
└─► UI calls app-only tools to load/refresh data
|
||
|
|
```
|
||
|
|
|
||
|
|
The link is declared once with `#[RendersApp]` on the tool:
|
||
|
|
|
||
|
|
```php
|
||
|
|
#[RendersApp(resource: DashboardApp::class)]
|
||
|
|
class ShowDashboard extends Tool
|
||
|
|
{
|
||
|
|
public function handle(Request $request): Response
|
||
|
|
{
|
||
|
|
return Response::text('Dashboard loaded.');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
After that, the host handles fetching and rendering the resource automatically — you never reference the URI by hand.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Architecture Overview
|
||
|
|
|
||
|
|
MCP Apps add interactive UI to the Model Context Protocol. The server returns self-contained HTML with all JS/CSS inlined. The host renders it in a sandboxed iframe. Apps communicate back via `createMcpApp()` — a pre-bundled global implementing the MCP UI PostMessage protocol.
|
||
|
|
|
||
|
|
```
|
||
|
|
┌─────────────────────────────────────────────┐
|
||
|
|
│ Host (Claude, ChatGPT, VS Code) │
|
||
|
|
│ ┌───────────────────────────────────────┐ │
|
||
|
|
│ │ Sandboxed iframe │ │
|
||
|
|
│ │ ┌─────────────────────────────────┐ │ │
|
||
|
|
│ │ │ Your MCP App (HTML/JS/CSS) │ │ │
|
||
|
|
│ │ │ - Rendered by AppResource │ │ │
|
||
|
|
│ │ │ - Single self-contained HTML │ │ │
|
||
|
|
│ │ │ - Themed via host CSS vars │ │ │
|
||
|
|
│ │ └─────────────────────────────────┘ │ │
|
||
|
|
│ └───────────────────────────────────────┘ │
|
||
|
|
└──────────────────┬──────────────────────────┘
|
||
|
|
│ MCP Protocol (JSON-RPC)
|
||
|
|
┌──────────────────▼──────────────────────────┐
|
||
|
|
│ Laravel MCP Server │
|
||
|
|
│ - AppResource → self-contained HTML │
|
||
|
|
│ - Tool #[RendersApp] → triggers UI display │
|
||
|
|
│ - resources/read → serves HTML + _meta.ui │
|
||
|
|
└─────────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
The server automatically advertises `io.modelcontextprotocol/ui` capability when any `AppResource` is registered. The client declares support in `capabilities.extensions["io.modelcontextprotocol/ui"]` during the initialize handshake.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Server-Side
|
||
|
|
|
||
|
|
Minimal case — `handle()` renders the Blade view, entire app lives there:
|
||
|
|
|
||
|
|
```php
|
||
|
|
class DashboardApp extends AppResource
|
||
|
|
{
|
||
|
|
public function handle(Request $request): Response
|
||
|
|
{
|
||
|
|
return Response::view('mcp.dashboard-app', [
|
||
|
|
'title' => $this->title(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Auto-renders `resources/views/mcp/dashboard-app.blade.php` with `$title` available via `$this->title()`.
|
||
|
|
|
||
|
|
Override `handle()` only when passing additional server-side data:
|
||
|
|
|
||
|
|
```php
|
||
|
|
class AnalyticsDashboard extends AppResource
|
||
|
|
{
|
||
|
|
public function handle(Request $request): Response
|
||
|
|
{
|
||
|
|
return Response::view('mcp.analytics-dashboard', [
|
||
|
|
'title' => $this->title(),
|
||
|
|
'metrics' => Metric::latest()->take(10)->get(),
|
||
|
|
'totalUsers' => User::count(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`Response::view($view, $data = [], $mergeData = [])` renders a Blade view and returns it as text.
|
||
|
|
|
||
|
|
`Response::html($path)` reads an HTML file from disk and returns its content. Relative paths resolve via `resource_path()`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
class StaticApp extends AppResource
|
||
|
|
{
|
||
|
|
public function handle(Request $request): Response
|
||
|
|
{
|
||
|
|
return Response::html('mcp/static-app.html');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### AppMeta Configuration
|
||
|
|
|
||
|
|
The simplest way to configure UI metadata is via the `#[AppMeta]` attribute directly on your resource class:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Laravel\Mcp\Server\Attributes\AppMeta;
|
||
|
|
use Laravel\Mcp\Server\Ui\Enums\Library;
|
||
|
|
use Laravel\Mcp\Server\Ui\Enums\Permission;
|
||
|
|
|
||
|
|
#[AppMeta(
|
||
|
|
connectDomains: ['https://api.stripe.com'],
|
||
|
|
permissions: [Permission::Camera, Permission::ClipboardWrite],
|
||
|
|
prefersBorder: true,
|
||
|
|
libraries: [Library::Tailwind, Library::Alpine],
|
||
|
|
)]
|
||
|
|
class PaymentsResource extends AppResource
|
||
|
|
{
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
For dynamic or computed configuration, override `appMeta()` instead:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Laravel\Mcp\Server\Ui\AppMeta;
|
||
|
|
|
||
|
|
public function appMeta(): AppMeta
|
||
|
|
{
|
||
|
|
return AppMeta::make()
|
||
|
|
->csp(Csp::make()->connectDomains(config('services.api.domains')))
|
||
|
|
->permissions(Permissions::make()->allow(Permission::Camera))
|
||
|
|
->libraries(Library::Tailwind)
|
||
|
|
->domain('sandbox.example.com');
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Permission Enum
|
||
|
|
|
||
|
|
Use the `Permission` enum for type-safe permission configuration:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Laravel\Mcp\Server\Ui\Enums\Permission;
|
||
|
|
|
||
|
|
Permission::Camera // 'camera'
|
||
|
|
Permission::Microphone // 'microphone'
|
||
|
|
Permission::Geolocation // 'geolocation'
|
||
|
|
Permission::ClipboardWrite // 'clipboardWrite'
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Csp
|
||
|
|
|
||
|
|
Controls what external domains the iframe can access:
|
||
|
|
|
||
|
|
```php
|
||
|
|
Csp::make()
|
||
|
|
->connectDomains(['https://api.example.com']) // fetch, XHR, WebSocket origins
|
||
|
|
->resourceDomains(['https://cdn.example.com']) // images, scripts, fonts, media
|
||
|
|
->frameDomains(['https://embed.example.com']) // nested iframe origins
|
||
|
|
->baseUriDomains(['https://base.example.com']); // base URI origins
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Permissions
|
||
|
|
|
||
|
|
```php
|
||
|
|
Permissions::make()->allow(Permission::Camera, Permission::ClipboardWrite);
|
||
|
|
|
||
|
|
Permissions::make()
|
||
|
|
->camera()
|
||
|
|
->microphone()
|
||
|
|
->geolocation()
|
||
|
|
->clipboardWrite();
|
||
|
|
```
|
||
|
|
|
||
|
|
Each enabled permission serializes as `"camera": {}` per the MCP spec.
|
||
|
|
|
||
|
|
#### AppMeta
|
||
|
|
|
||
|
|
```php
|
||
|
|
AppMeta::make()
|
||
|
|
->csp(Csp::make()->connectDomains([...]))
|
||
|
|
->permissions(Permissions::make()->allow(Permission::Camera))
|
||
|
|
->libraries(Library::Tailwind, Library::Alpine)
|
||
|
|
->domain('sandbox.example.com') // dedicated sandbox origin (OAuth/CORS)
|
||
|
|
->prefersBorder(false);
|
||
|
|
```
|
||
|
|
|
||
|
|
`prefersBorder` defaults to `true`. `toArray()` omits null fields and empty nested objects. Library CDN domains are automatically merged into `csp.resourceDomains`.
|
||
|
|
|
||
|
|
#### domain
|
||
|
|
|
||
|
|
The `domain` field provides a stable origin that external APIs can allowlist for CORS. It is automatically resolved from `config('app.url')` (your `APP_URL` env variable) via `resolvedAppMeta()`, so most apps need no configuration. Override only when a resource needs a different origin:
|
||
|
|
|
||
|
|
```php
|
||
|
|
#[AppMeta(domain: 'custom.example.com')]
|
||
|
|
class PaymentsResource extends AppResource
|
||
|
|
{
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Library Scripts
|
||
|
|
|
||
|
|
The `libraries` parameter adds pre-configured CDN scripts to the `<head>` of your app. Available libraries:
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Laravel\Mcp\Server\Ui\Enums\Library;
|
||
|
|
|
||
|
|
Library::Tailwind // Tailwind CSS CDN + dark mode config
|
||
|
|
Library::Alpine // Alpine.js CDN + x-cloak style
|
||
|
|
```
|
||
|
|
|
||
|
|
When libraries are specified, the package automatically:
|
||
|
|
|
||
|
|
1. Injects the CDN `<script>` tags into the Blade view's `<head>` (after the MCP SDK, before your `<x-slot:head>`)
|
||
|
|
2. Merges each library's CDN domains into `csp.resourceDomains` so the host allows loading them
|
||
|
|
|
||
|
|
Via attribute:
|
||
|
|
|
||
|
|
```php
|
||
|
|
#[AppMeta(libraries: [Library::Tailwind])]
|
||
|
|
class StyledApp extends AppResource
|
||
|
|
{
|
||
|
|
// Tailwind is available in the Blade view — no extra setup
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Via fluent builder:
|
||
|
|
|
||
|
|
```php
|
||
|
|
public function appMeta(): AppMeta
|
||
|
|
{
|
||
|
|
return AppMeta::make()
|
||
|
|
->libraries(Library::Tailwind, Library::Alpine);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## View Layer
|
||
|
|
|
||
|
|
### `<x-mcp::app>` Blade Component
|
||
|
|
|
||
|
|
Renders a complete self-contained HTML document with the MCP SDK inlined. `createMcpApp` is available globally.
|
||
|
|
|
||
|
|
```blade
|
||
|
|
<x-mcp::app title="Dashboard App">
|
||
|
|
<x-slot:head>
|
||
|
|
<script type="module">
|
||
|
|
createMcpApp(async (app) => {
|
||
|
|
document.getElementById('run-btn').addEventListener('click', async () => {
|
||
|
|
const result = await app.callServerTool({ name: 'tool-name', arguments: {} });
|
||
|
|
document.getElementById('output').textContent = result.content[0]?.text ?? '';
|
||
|
|
});
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
</x-slot:head>
|
||
|
|
|
||
|
|
<div id="app">
|
||
|
|
<button id="run-btn">Run</button>
|
||
|
|
<p id="output"></p>
|
||
|
|
</div>
|
||
|
|
</x-mcp::app>
|
||
|
|
```
|
||
|
|
|
||
|
|
**Props and slots:**
|
||
|
|
|
||
|
|
| Name | Type | Description |
|
||
|
|
| ------------- | ------------- | ---------------------------------------------------- |
|
||
|
|
| `title` | Prop | Sets `<title>`. Optional. |
|
||
|
|
| `head` | Named slot | Injected into `<head>` after the inlined SDK script. |
|
||
|
|
| Default slot | Slot | Body content. |
|
||
|
|
| `$attributes` | Attribute bag | Forwarded to `<body>` (e.g. `class="dark"`). |
|
||
|
|
|
||
|
|
The SDK is loaded from the `mcp.sdk` singleton (registered by `McpServiceProvider`) and inlined directly in a `<script>` tag. Library scripts (Tailwind, Alpine) configured via `#[AppMeta]` are injected after the SDK and before the `head` slot.
|
||
|
|
|
||
|
|
Publish the component: `php artisan vendor:publish --tag=mcp-views`.
|
||
|
|
|
||
|
|
To pass server-side data to JS, embed it as `data-*` attributes:
|
||
|
|
|
||
|
|
```blade
|
||
|
|
<div id="app" data-users="{{ $users->toJson() }}">
|
||
|
|
...
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
```js
|
||
|
|
const users = JSON.parse(document.getElementById("app").dataset.users);
|
||
|
|
```
|
||
|
|
|
||
|
|
## Client-Side
|
||
|
|
|
||
|
|
This package provides a simple MCP client library to easily work with client interactions.
|
||
|
|
|
||
|
|
### createMcpApp
|
||
|
|
|
||
|
|
Pre-bundled and inlined automatically — no npm install or imports required.
|
||
|
|
|
||
|
|
```js
|
||
|
|
createMcpApp(async (app) => {
|
||
|
|
// app is ready — connection established, theming applied
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Tools
|
||
|
|
|
||
|
|
#### app.callServerTool()
|
||
|
|
|
||
|
|
Accepts an object or positional arguments:
|
||
|
|
|
||
|
|
```js
|
||
|
|
// Object form
|
||
|
|
const result = await app.callServerTool({ name: 'get-analytics', arguments: { dateRange: '7d' } });
|
||
|
|
|
||
|
|
// Positional form
|
||
|
|
const result = await app.callServerTool('get-analytics', { dateRange: '7d' });
|
||
|
|
|
||
|
|
// result structure depends on the server's tool response
|
||
|
|
const text = result.content[0]?.text ?? "";
|
||
|
|
```
|
||
|
|
|
||
|
|
All tool results share a standard structure:
|
||
|
|
|
||
|
|
| Property | Type | Description |
|
||
|
|
| --------- | --------- | ------------------------------------------------------------------------- |
|
||
|
|
| `content` | `Array` | Content items returned by the tool (each has `type` and `text` or `data`) |
|
||
|
|
| `isError` | `boolean` | `true` when the tool returned an error response |
|
||
|
|
|
||
|
|
Always check `result.isError` before consuming `content`. See [Error Handling](#error-handling) for a full example.
|
||
|
|
|
||
|
|
### Resources
|
||
|
|
|
||
|
|
#### app.listResources()
|
||
|
|
|
||
|
|
```js
|
||
|
|
const resources = await app.listResources();
|
||
|
|
// or with cursor for pagination
|
||
|
|
const resources = await app.listResources("cursor-value");
|
||
|
|
// or object form
|
||
|
|
const resources = await app.listResources({ cursor: "cursor-value" });
|
||
|
|
```
|
||
|
|
|
||
|
|
#### app.readResource()
|
||
|
|
|
||
|
|
```js
|
||
|
|
const resource = await app.readResource("ui://my-resource");
|
||
|
|
// or object form
|
||
|
|
const resource = await app.readResource({ uri: "ui://my-resource" });
|
||
|
|
```
|
||
|
|
|
||
|
|
### Messaging
|
||
|
|
|
||
|
|
#### app.sendMessage()
|
||
|
|
|
||
|
|
Send a message to the model (creates a conversation turn):
|
||
|
|
|
||
|
|
```js
|
||
|
|
// Object form with structured content
|
||
|
|
await app.sendMessage({
|
||
|
|
role: "user",
|
||
|
|
content: [{ type: "text", text: "User submitted the form." }],
|
||
|
|
});
|
||
|
|
|
||
|
|
// Shorthand — plain string content with optional role (defaults to 'user')
|
||
|
|
await app.sendMessage("User submitted the form.");
|
||
|
|
await app.sendMessage("System event occurred.", "user");
|
||
|
|
```
|
||
|
|
|
||
|
|
### Host Context
|
||
|
|
|
||
|
|
#### app.getHostContext()
|
||
|
|
|
||
|
|
Returns the current host context, including theme and style variables:
|
||
|
|
|
||
|
|
```js
|
||
|
|
const ctx = app.getHostContext();
|
||
|
|
ctx?.theme; // 'light' | 'dark'
|
||
|
|
ctx?.styles?.variables; // CSS variable map from host
|
||
|
|
ctx?.styles?.css?.fonts; // font CSS from host
|
||
|
|
```
|
||
|
|
|
||
|
|
#### app.getHostInfo()
|
||
|
|
|
||
|
|
```js
|
||
|
|
const info = app.getHostInfo();
|
||
|
|
```
|
||
|
|
|
||
|
|
#### app.getHostCapabilities()
|
||
|
|
|
||
|
|
```js
|
||
|
|
const caps = app.getHostCapabilities();
|
||
|
|
```
|
||
|
|
|
||
|
|
### Navigation & Files
|
||
|
|
|
||
|
|
#### app.openLink()
|
||
|
|
|
||
|
|
```js
|
||
|
|
await app.openLink("https://example.com");
|
||
|
|
// or object form
|
||
|
|
await app.openLink({ url: "https://example.com" });
|
||
|
|
```
|
||
|
|
|
||
|
|
#### app.downloadFile()
|
||
|
|
|
||
|
|
```js
|
||
|
|
await app.downloadFile("file contents here");
|
||
|
|
// or object form
|
||
|
|
await app.downloadFile({ contents: "file contents here" });
|
||
|
|
```
|
||
|
|
|
||
|
|
### Display
|
||
|
|
|
||
|
|
#### app.requestDisplayMode()
|
||
|
|
|
||
|
|
```js
|
||
|
|
await app.requestDisplayMode("fullscreen");
|
||
|
|
// or object form
|
||
|
|
await app.requestDisplayMode({ mode: "fullscreen" });
|
||
|
|
```
|
||
|
|
|
||
|
|
#### app.resize() / app.autoResize()
|
||
|
|
|
||
|
|
`resize()` sends a one-time size notification. `autoResize()` uses `ResizeObserver` to continuously notify the host of size changes. It returns a cleanup function that disconnects the observer — useful if you need to stop observing before teardown. The observer is also automatically disconnected on teardown.
|
||
|
|
|
||
|
|
```js
|
||
|
|
const stopObserving = app.autoResize();
|
||
|
|
|
||
|
|
// Later, if needed:
|
||
|
|
stopObserving();
|
||
|
|
```
|
||
|
|
|
||
|
|
### Model Context
|
||
|
|
|
||
|
|
#### app.updateModelContext()
|
||
|
|
|
||
|
|
```js
|
||
|
|
await app.updateModelContext({ key: "value" });
|
||
|
|
```
|
||
|
|
|
||
|
|
### Lifecycle
|
||
|
|
|
||
|
|
#### app.requestTeardown()
|
||
|
|
|
||
|
|
Sends a teardown notification to the host.
|
||
|
|
|
||
|
|
```js
|
||
|
|
app.requestTeardown();
|
||
|
|
```
|
||
|
|
|
||
|
|
### Logging
|
||
|
|
|
||
|
|
#### app.sendLog()
|
||
|
|
|
||
|
|
```js
|
||
|
|
// Positional form
|
||
|
|
await app.sendLog("info", "Processing started", "my-logger");
|
||
|
|
|
||
|
|
// Object form
|
||
|
|
await app.sendLog({
|
||
|
|
level: "info",
|
||
|
|
data: "Processing started",
|
||
|
|
logger: "my-logger",
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Event Handlers
|
||
|
|
|
||
|
|
Register callbacks for host-side events. Tool input/result/cancelled events are queued until a handler is registered, then flushed.
|
||
|
|
|
||
|
|
```js
|
||
|
|
createMcpApp(async (app) => {
|
||
|
|
app.onToolInput((params) => {
|
||
|
|
/* tool input received */
|
||
|
|
});
|
||
|
|
app.onToolInputPartial((params) => {
|
||
|
|
/* partial tool input */
|
||
|
|
});
|
||
|
|
app.onToolResult((params) => {
|
||
|
|
/* tool result received */
|
||
|
|
});
|
||
|
|
app.onToolCancelled((params) => {
|
||
|
|
/* tool was cancelled */
|
||
|
|
});
|
||
|
|
app.onHostContextChanged((ctx) => {
|
||
|
|
/* theme/styles changed */
|
||
|
|
});
|
||
|
|
app.onTeardown(async () => {
|
||
|
|
/* cleanup before teardown */
|
||
|
|
});
|
||
|
|
app.onCallTool(async (params) => {
|
||
|
|
/* host requests tool call */
|
||
|
|
});
|
||
|
|
app.onListTools(async (params) => {
|
||
|
|
/* host requests tool list */
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Host Theming
|
||
|
|
|
||
|
|
`createMcpApp` automatically applies host theming on connect and on context change:
|
||
|
|
|
||
|
|
- Sets `data-theme` attribute and `color-scheme` on `<html>`
|
||
|
|
- Applies CSS variables from `hostContext.styles.variables` to `:root`
|
||
|
|
- Injects font CSS from `hostContext.styles.css.fonts` into a `<style>` tag
|
||
|
|
|
||
|
|
The specific CSS variables available depend on the host. Always provide fallback values — use `light-dark()` for theme-aware defaults:
|
||
|
|
|
||
|
|
```css
|
||
|
|
:root {
|
||
|
|
--color-background-primary: light-dark(#ffffff, #171717);
|
||
|
|
--color-text-primary: light-dark(#171717, #fafafa);
|
||
|
|
--color-text-secondary: light-dark(#525252, #a3a3a3);
|
||
|
|
--color-border-primary: light-dark(#e5e5e5, #404040);
|
||
|
|
--font-sans: system-ui, -apple-system, sans-serif;
|
||
|
|
--border-radius-md: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
body {
|
||
|
|
font-family: var(--font-sans);
|
||
|
|
background: var(--color-background-primary);
|
||
|
|
color: var(--color-text-primary);
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.card {
|
||
|
|
background: var(--color-background-secondary);
|
||
|
|
border: 1px solid var(--color-border-primary);
|
||
|
|
border-radius: var(--border-radius-md);
|
||
|
|
padding: 1rem;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Tool-to-UI Linking
|
||
|
|
|
||
|
|
### #[RendersApp] Attribute
|
||
|
|
|
||
|
|
Associates a Tool with a UI Resource. When the tool is called, the host fetches and renders the linked resource.
|
||
|
|
|
||
|
|
```php
|
||
|
|
use Laravel\Mcp\Server\Attributes\RendersApp;
|
||
|
|
use Laravel\Mcp\Server\Ui\Enums\Visibility;
|
||
|
|
|
||
|
|
// Both model and app can call this tool (default)
|
||
|
|
#[RendersApp(resource: DashboardApp::class)]
|
||
|
|
class ShowDashboard extends Tool { ... }
|
||
|
|
|
||
|
|
// Only the app can call this tool (private to the UI)
|
||
|
|
#[RendersApp(resource: DashboardApp::class, visibility: [Visibility::App])]
|
||
|
|
class RefreshDashboardData extends Tool { ... }
|
||
|
|
```
|
||
|
|
|
||
|
|
**Visibility:**
|
||
|
|
|
||
|
|
The `Visibility` enum (`Laravel\Mcp\Server\Ui\Enums\Visibility`) has two cases: `Model` and `App`. The default is `[Visibility::Model, Visibility::App]`.
|
||
|
|
|
||
|
|
| Visibility | Model | App | Use case |
|
||
|
|
| -------------------------------------- | ----- | --- | ------------------------------------------------------ |
|
||
|
|
| `[Visibility::Model, Visibility::App]` | Yes | Yes | Primary tools that trigger UI display |
|
||
|
|
| `[Visibility::App]` | No | Yes | Backend actions the UI calls (refresh, save, paginate) |
|
||
|
|
| `[Visibility::Model]` | Yes | No | Model-only tools linked to a UI |
|
||
|
|
|
||
|
|
### Primary + Private Pattern
|
||
|
|
|
||
|
|
```php
|
||
|
|
#[RendersApp(resource: DashboardApp::class)]
|
||
|
|
class ShowDashboard extends Tool
|
||
|
|
{
|
||
|
|
public function handle(Request $request): Response
|
||
|
|
{
|
||
|
|
return Response::text('Dashboard loaded.');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[RendersApp(resource: DashboardApp::class, visibility: [Visibility::App])]
|
||
|
|
class GetDashboardMetrics extends Tool
|
||
|
|
{
|
||
|
|
public function handle(Request $request): Response
|
||
|
|
{
|
||
|
|
return Response::json(Metric::latest()->take(50)->get());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Testing
|
||
|
|
|
||
|
|
```php
|
||
|
|
it('returns html content', function () {
|
||
|
|
MyServer::readResource(DashboardApp::class)
|
||
|
|
->assertSee('<div id="app">');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('has correct mime type and uri scheme', function () {
|
||
|
|
$resource = new DashboardApp;
|
||
|
|
$data = $resource->toArray();
|
||
|
|
|
||
|
|
expect($data['mimeType'])->toBe('text/html;profile=mcp-app')
|
||
|
|
->and($data['_meta']['ui'])->toBeArray()
|
||
|
|
->and($resource->uri())->toStartWith('ui://');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('configures ui meta correctly', function () {
|
||
|
|
$meta = (new DashboardApp)->resolvedAppMeta();
|
||
|
|
|
||
|
|
expect($meta['csp']['connectDomains'])->toContain('https://api.example.com')
|
||
|
|
->and($meta['permissions'])->toHaveKey('clipboardWrite');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('includes ui metadata in tool listing', function () {
|
||
|
|
MyServer::listTools()->assertSee('show-dashboard');
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Patterns
|
||
|
|
|
||
|
|
### Real-time Polling
|
||
|
|
|
||
|
|
Use app-only tools to fetch fresh data at regular intervals from the UI:
|
||
|
|
|
||
|
|
```php
|
||
|
|
#[RendersApp(resource: MonitorApp::class, visibility: [Visibility::App])]
|
||
|
|
class GetMonitorData extends Tool
|
||
|
|
{
|
||
|
|
protected string $description = 'Fetch latest monitor metrics';
|
||
|
|
|
||
|
|
public function handle(Request $request): Response
|
||
|
|
{
|
||
|
|
return Response::json([
|
||
|
|
'cpu' => sys_getloadavg()[0],
|
||
|
|
'memory' => memory_get_usage(true),
|
||
|
|
'timestamp' => now()->toISOString(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```js
|
||
|
|
createMcpApp(async (app) => {
|
||
|
|
async function poll() {
|
||
|
|
const result = await app.callServerTool('get-monitor-data');
|
||
|
|
const data = JSON.parse(result.content[0]?.text ?? '{}');
|
||
|
|
document.getElementById('cpu').textContent = data.cpu;
|
||
|
|
}
|
||
|
|
|
||
|
|
setInterval(poll, 2000);
|
||
|
|
poll();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Chunked Data Loading
|
||
|
|
|
||
|
|
For large datasets, implement pagination via app-only tools:
|
||
|
|
|
||
|
|
```php
|
||
|
|
#[RendersApp(resource: LogViewerApp::class, visibility: [Visibility::App])]
|
||
|
|
class GetLogChunk extends Tool
|
||
|
|
{
|
||
|
|
protected string $description = 'Fetch a chunk of log entries';
|
||
|
|
|
||
|
|
public function schema(JsonSchema $schema): array
|
||
|
|
{
|
||
|
|
return [
|
||
|
|
'offset' => $schema->integer()->description('Byte offset to start from')->required(),
|
||
|
|
'limit' => $schema->integer()->description('Max bytes to return'),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
public function handle(Request $request): Response
|
||
|
|
{
|
||
|
|
$request->validate(['offset' => 'required|integer', 'limit' => 'integer']);
|
||
|
|
|
||
|
|
$offset = $request->get('offset');
|
||
|
|
$limit = $request->get('limit', 500_000);
|
||
|
|
$content = Storage::get('logs/app.log');
|
||
|
|
$chunk = substr($content, $offset, $limit);
|
||
|
|
|
||
|
|
return Response::json([
|
||
|
|
'data' => $chunk,
|
||
|
|
'offset' => $offset,
|
||
|
|
'totalBytes' => strlen($content),
|
||
|
|
'hasMore' => ($offset + $limit) < strlen($content),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Binary Resource Serving
|
||
|
|
|
||
|
|
Deliver images and binary content through MCP resources using `Response::blob()`:
|
||
|
|
|
||
|
|
```php
|
||
|
|
#[RendersApp(resource: GalleryApp::class, visibility: [Visibility::App])]
|
||
|
|
class GetImage extends Tool
|
||
|
|
{
|
||
|
|
protected string $description = 'Fetch an image by ID';
|
||
|
|
|
||
|
|
public function handle(Request $request): Response
|
||
|
|
{
|
||
|
|
$request->validate(['id' => 'required|integer']);
|
||
|
|
|
||
|
|
$image = Image::findOrFail($request->get('id'));
|
||
|
|
$data = base64_encode(Storage::get($image->path));
|
||
|
|
|
||
|
|
return Response::blob($data);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
In the client, convert the base64 blob to a data URI for rendering:
|
||
|
|
|
||
|
|
```js
|
||
|
|
const result = await app.callServerTool('get-image', { id: 42 });
|
||
|
|
const blob = result.content[0];
|
||
|
|
img.src = `data:${blob.mimeType};base64,${blob.data}`;
|
||
|
|
```
|
||
|
|
|
||
|
|
### Streaming Argument Previews
|
||
|
|
|
||
|
|
Use `onToolInputPartial` to show previews as the model streams tool arguments:
|
||
|
|
|
||
|
|
```js
|
||
|
|
createMcpApp(async (app) => {
|
||
|
|
app.onToolInputPartial((params) => {
|
||
|
|
try {
|
||
|
|
const partial = JSON.parse(params.arguments);
|
||
|
|
if (partial.query) {
|
||
|
|
document.getElementById("preview").textContent = partial.query;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// partial JSON — ignore until parseable
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
app.onToolResult((params) => {
|
||
|
|
const data = JSON.parse(params.result.content[0]?.text ?? "{}");
|
||
|
|
renderResults(data);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### View State Persistence
|
||
|
|
|
||
|
|
Use `localStorage` to preserve UI state across re-renders. For important state, persist server-side via an app-only tool:
|
||
|
|
|
||
|
|
```js
|
||
|
|
createMcpApp(async (app) => {
|
||
|
|
const STATE_KEY = "dashboard-view-state";
|
||
|
|
|
||
|
|
// Restore from localStorage
|
||
|
|
const saved = JSON.parse(localStorage.getItem(STATE_KEY) || "{}");
|
||
|
|
if (saved.activeTab) selectTab(saved.activeTab);
|
||
|
|
|
||
|
|
// Save on interaction
|
||
|
|
function saveState(state) {
|
||
|
|
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
||
|
|
}
|
||
|
|
|
||
|
|
// For durable state, persist server-side
|
||
|
|
async function saveServerState(state) {
|
||
|
|
await app.callServerTool('save-dashboard-state', { state: JSON.stringify(state) });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Fullscreen Toggling
|
||
|
|
|
||
|
|
Switch between inline and fullscreen display modes and react to mode changes:
|
||
|
|
|
||
|
|
```js
|
||
|
|
createMcpApp(async (app) => {
|
||
|
|
document.getElementById("expand-btn").addEventListener("click", () => {
|
||
|
|
app.requestDisplayMode("fullscreen");
|
||
|
|
});
|
||
|
|
|
||
|
|
app.onHostContextChanged((ctx) => {
|
||
|
|
document.body.classList.toggle(
|
||
|
|
"fullscreen",
|
||
|
|
ctx.displayMode === "fullscreen",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Model Context Updates
|
||
|
|
|
||
|
|
Keep the model informed about what the user is viewing so it can provide relevant assistance:
|
||
|
|
|
||
|
|
```js
|
||
|
|
createMcpApp(async (app) => {
|
||
|
|
async function notifyContext(view, detail) {
|
||
|
|
await app.updateModelContext({
|
||
|
|
currentView: view,
|
||
|
|
detail: detail,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Notify on tab change
|
||
|
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||
|
|
tab.addEventListener("click", () => {
|
||
|
|
notifyContext(tab.dataset.view, { filters: getActiveFilters() });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// For large payloads, follow up with sendMessage
|
||
|
|
await app.updateModelContext({ currentView: "report", rows: 5000 });
|
||
|
|
await app.sendMessage("The user is viewing a report with 5000 rows.");
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pause Offscreen Views
|
||
|
|
|
||
|
|
Conserve resources by pausing animations and polling when the view is not visible:
|
||
|
|
|
||
|
|
```js
|
||
|
|
createMcpApp(async (app) => {
|
||
|
|
let pollInterval = null;
|
||
|
|
|
||
|
|
function startPolling() {
|
||
|
|
if (!pollInterval) {
|
||
|
|
pollInterval = setInterval(fetchData, 2000);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function stopPolling() {
|
||
|
|
clearInterval(pollInterval);
|
||
|
|
pollInterval = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const observer = new IntersectionObserver(([entry]) => {
|
||
|
|
entry.isIntersecting ? startPolling() : stopPolling();
|
||
|
|
});
|
||
|
|
|
||
|
|
observer.observe(document.documentElement);
|
||
|
|
startPolling();
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### Error Handling
|
||
|
|
|
||
|
|
Return `Response::error()` from tools and use `updateModelContext()` to signal degraded state:
|
||
|
|
|
||
|
|
```php
|
||
|
|
class ProcessData extends Tool
|
||
|
|
{
|
||
|
|
public function handle(Request $request): Response
|
||
|
|
{
|
||
|
|
$request->validate(['input' => 'required|string']);
|
||
|
|
|
||
|
|
if (strlen($request->get('input')) > 10_000) {
|
||
|
|
return Response::error('Input exceeds 10KB limit.');
|
||
|
|
}
|
||
|
|
|
||
|
|
return Response::json(process($request->get('input')));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
```js
|
||
|
|
createMcpApp(async (app) => {
|
||
|
|
const result = await app.callServerTool('process-data', { input: value });
|
||
|
|
|
||
|
|
if (result.isError) {
|
||
|
|
document.getElementById("error").textContent =
|
||
|
|
result.content[0]?.text ?? "Unknown error";
|
||
|
|
await app.updateModelContext({
|
||
|
|
state: "error",
|
||
|
|
message: result.content[0]?.text,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
renderOutput(JSON.parse(result.content[0]?.text ?? "{}"));
|
||
|
|
});
|
||
|
|
```
|