refactor: susun semula struktur folder — Laravel source ke src/
This commit is contained in:
106
vendor/laravel/mcp/resources/boost/skills/mcp-development/SKILL.blade.php
vendored
Normal file
106
vendor/laravel/mcp/resources/boost/skills/mcp-development/SKILL.blade.php
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: mcp-development
|
||||
description: "Use this skill for Laravel MCP development. Trigger when creating or editing MCP tools, resources, prompts, servers, or UI apps in Laravel projects. Covers: artisan make:mcp-* generators, routes/ai.php, Tool/Resource/Prompt/AppResource classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, MCP debugging, MCP UI apps, the x-mcp::app Blade component, createMcpApp(), default AppResource handle() auto-infers view from class name, Response::view(), AppMeta/Csp/Permissions/appMeta() configuration, #[RendersApp] attribute, Library enum for CDN libraries (Tailwind, Alpine), and host theming via CSS variables. Use this whenever the user mentions MCP apps, MCP UI, interactive MCP resources, styling MCP apps with Tailwind or Alpine, or building visual interfaces for AI agents."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
@php
|
||||
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
|
||||
@endphp
|
||||
# MCP Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
||||
|
||||
For MCP UI apps (interactive HTML resources), read `references/app.md` — it covers the full architecture, host theming CSS variables, tool-to-UI linking patterns, library scripts (Tailwind, Alpine via `Library`), and real-world examples.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
Register MCP servers in `routes/ai.php`:
|
||||
|
||||
@boostsnippet("Register MCP Server", "php")
|
||||
use Laravel\Mcp\Facades\Mcp;
|
||||
|
||||
Mcp::web();
|
||||
@endboostsnippet
|
||||
|
||||
### Creating MCP Primitives
|
||||
|
||||
```bash
|
||||
{{ $assist->artisanCommand('make:mcp-tool ToolName') }} # Create a tool
|
||||
{{ $assist->artisanCommand('make:mcp-resource ResourceName') }} # Create a resource
|
||||
{{ $assist->artisanCommand('make:mcp-prompt PromptName') }} # Create a prompt
|
||||
{{ $assist->artisanCommand('make:mcp-server ServerName') }} # Create a server
|
||||
{{ $assist->artisanCommand('make:mcp-app-resource DashboardApp') }} # Create a UI app (2 files)
|
||||
```
|
||||
|
||||
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
||||
|
||||
### Tools
|
||||
|
||||
@boostsnippet("MCP Tool Example", "php")
|
||||
use Illuminate\Json\Schema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
class MyTool extends Tool
|
||||
{
|
||||
protected string $description = 'Describe what this tool does';
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'name' => $schema->string()->description('The name parameter')->required(),
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$request->validate(['name' => 'required|string']);
|
||||
|
||||
return Response::text('Hello, '.$request->get('name'));
|
||||
}
|
||||
}
|
||||
@endboostsnippet
|
||||
|
||||
### Registering Primitives in a Server
|
||||
|
||||
@boostsnippet("Register Primitives in MCP Server", "php")
|
||||
use Laravel\Mcp\Server;
|
||||
|
||||
class AppServer extends Server
|
||||
{
|
||||
protected array $tools = [
|
||||
\App\Mcp\Tools\MyTool::class,
|
||||
];
|
||||
|
||||
protected array $resources = [
|
||||
\App\Mcp\Resources\MyResource::class,
|
||||
];
|
||||
|
||||
protected array $prompts = [
|
||||
\App\Mcp\Prompts\MyPrompt::class,
|
||||
];
|
||||
}
|
||||
@endboostsnippet
|
||||
|
||||
## MCP UI Apps
|
||||
|
||||
For MCP UI apps, read `references/app.md` — it covers quick start examples, full architecture, AppMeta/Csp/Permissions, `#[RendersApp]` tool linking, library scripts (Tailwind/Alpine via `Library`), host theming CSS variables, and real-world patterns.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Check `routes/ai.php` for proper registration
|
||||
2. Test tool via MCP client
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Running `mcp:start` command (it hangs waiting for input)
|
||||
- Using HTTPS locally with Node-based MCP clients
|
||||
- Not using `search-docs` for the latest MCP documentation
|
||||
- Not registering MCP server routes in `routes/ai.php`
|
||||
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically
|
||||
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
||||
940
vendor/laravel/mcp/resources/boost/skills/mcp-development/references/app.md
vendored
Normal file
940
vendor/laravel/mcp/resources/boost/skills/mcp-development/references/app.md
vendored
Normal file
@@ -0,0 +1,940 @@
|
||||
# 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 ?? "{}"));
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user