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:
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:
<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.
Every MCP App is built from two parts linked together:
_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:
#[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.
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.
Minimal case — handle() renders the Blade view, entire app lives there:
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:
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():
class StaticApp extends AppResource
{
public function handle(Request $request): Response
{
return Response::html('mcp/static-app.html');
}
}
The simplest way to configure UI metadata is via the #[AppMeta] attribute directly on your resource class:
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:
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');
}
Use the Permission enum for type-safe permission configuration:
use Laravel\Mcp\Server\Ui\Enums\Permission;
Permission::Camera // 'camera'
Permission::Microphone // 'microphone'
Permission::Geolocation // 'geolocation'
Permission::ClipboardWrite // 'clipboardWrite'
Controls what external domains the iframe can access:
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::make()->allow(Permission::Camera, Permission::ClipboardWrite);
Permissions::make()
->camera()
->microphone()
->geolocation()
->clipboardWrite();
Each enabled permission serializes as "camera": {} per the MCP spec.
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.
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:
#[AppMeta(domain: 'custom.example.com')]
class PaymentsResource extends AppResource
{
// ...
}
The libraries parameter adds pre-configured CDN scripts to the <head> of your app. Available libraries:
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:
<script> tags into the Blade view's <head> (after the MCP SDK, before your <x-slot:head>)csp.resourceDomains so the host allows loading themVia attribute:
#[AppMeta(libraries: [Library::Tailwind])]
class StyledApp extends AppResource
{
// Tailwind is available in the Blade view — no extra setup
}
Via fluent builder:
public function appMeta(): AppMeta
{
return AppMeta::make()
->libraries(Library::Tailwind, Library::Alpine);
}
<x-mcp::app> Blade ComponentRenders a complete self-contained HTML document with the MCP SDK inlined. createMcpApp is available globally.
<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:
<div id="app" data-users="{{ $users->toJson() }}">
...
</div>
const users = JSON.parse(document.getElementById("app").dataset.users);
This package provides a simple MCP client library to easily work with client interactions.
Pre-bundled and inlined automatically — no npm install or imports required.
createMcpApp(async (app) => {
// app is ready — connection established, theming applied
});
Accepts an object or positional arguments:
// 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 for a full example.
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" });
const resource = await app.readResource("ui://my-resource");
// or object form
const resource = await app.readResource({ uri: "ui://my-resource" });
Send a message to the model (creates a conversation turn):
// 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");
Returns the current host context, including theme and style variables:
const ctx = app.getHostContext();
ctx?.theme; // 'light' | 'dark'
ctx?.styles?.variables; // CSS variable map from host
ctx?.styles?.css?.fonts; // font CSS from host
const info = app.getHostInfo();
const caps = app.getHostCapabilities();
await app.openLink("https://example.com");
// or object form
await app.openLink({ url: "https://example.com" });
await app.downloadFile("file contents here");
// or object form
await app.downloadFile({ contents: "file contents here" });
await app.requestDisplayMode("fullscreen");
// or object form
await app.requestDisplayMode({ mode: "fullscreen" });
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.
const stopObserving = app.autoResize();
// Later, if needed:
stopObserving();
await app.updateModelContext({ key: "value" });
Sends a teardown notification to the host.
app.requestTeardown();
// Positional form
await app.sendLog("info", "Processing started", "my-logger");
// Object form
await app.sendLog({
level: "info",
data: "Processing started",
logger: "my-logger",
});
Register callbacks for host-side events. Tool input/result/cancelled events are queued until a handler is registered, then flushed.
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 */
});
});
createMcpApp automatically applies host theming on connect and on context change:
data-theme attribute and color-scheme on <html>hostContext.styles.variables to :roothostContext.styles.css.fonts into a <style> tagThe specific CSS variables available depend on the host. Always provide fallback values — use light-dark() for theme-aware defaults:
: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;
}
Associates a Tool with a UI Resource. When the tool is called, the host fetches and renders the linked resource.
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 |
#[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());
}
}
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');
});
Use app-only tools to fetch fresh data at regular intervals from the UI:
#[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(),
]);
}
}
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();
});
For large datasets, implement pagination via app-only tools:
#[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),
]);
}
}
Deliver images and binary content through MCP resources using Response::blob():
#[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:
const result = await app.callServerTool('get-image', { id: 42 });
const blob = result.content[0];
img.src = `data:${blob.mimeType};base64,${blob.data}`;
Use onToolInputPartial to show previews as the model streams tool arguments:
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);
});
});
Use localStorage to preserve UI state across re-renders. For important state, persist server-side via an app-only tool:
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) });
}
});
Switch between inline and fullscreen display modes and react to mode changes:
createMcpApp(async (app) => {
document.getElementById("expand-btn").addEventListener("click", () => {
app.requestDisplayMode("fullscreen");
});
app.onHostContextChanged((ctx) => {
document.body.classList.toggle(
"fullscreen",
ctx.displayMode === "fullscreen",
);
});
});
Keep the model informed about what the user is viewing so it can provide relevant assistance:
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.");
});
Conserve resources by pausing animations and polling when the view is not visible:
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();
});
Return Response::error() from tools and use updateModelContext() to signal degraded state:
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')));
}
}
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 ?? "{}"));
});