* Add workspace MCP settings and token access controls. Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring, and workspace.token:mcp gating so assistants can connect without pulling in welcome/onboarding from the parent epic. Co-authored-by: Cursor <cursoragent@cursor.com> * Type MCP client config shapes instead of string checks. Encode http/config-root on each advanced client and tighten primary client ids so snippet generation does not branch on magic strings. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP settings follow-ups from review. Translate Ukrainian MCP copy, deep-link ChatGPT into connector creation, drop an unused asset and revoke arg, and assert PATs are rejected on the MCP endpoint. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP connected clients, revoke scope, and OAuth consent. List recoverable sessions with live refresh tokens, revoke only PATs, throttle registration alone, and block viewers from authorizing MCP. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify MCP OAuth route throttling to a single middleware group. Co-authored-by: Cursor <cursoragent@cursor.com> * Allow workspace viewers read-only MCP access with web policy writes. Mirror the web app: MCP connects on view + OAuth mcp:use, write tools enforce createPost/update/delete/manageAccounts/manageTeam, and demotion to Viewer keeps grants. Cover role denials, consent, and disconnect. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP tool authz with shared workspace helpers. Route ApiKey tools through AuthorizesMcpTool, fail closed on null user or policy argument, and resolve the current workspace before mutating. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant string casts on validated request data. Enum::from and validated() fields are already strings, so the casts add noise without changing behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Show only the current user's MCP connections in settings. Match API keys privacy: list and disconnect your own OAuth clients, not teammates' across the account. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant is_string guard before UpdatePostTool find. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined. * Refresh MCP connected clients with Inertia usePoll. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump laravel/mcp to 0.9.1 and add the TryPost server icon. Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop no-op ReflectionClass import in TryPostServerTest. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
940 lines
27 KiB
Markdown
940 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 ?? "{}"));
|
|
});
|
|
```
|