From 32cd725a54b5d9ba9d9b42262adbfc1c921f3827 Mon Sep 17 00:00:00 2001 From: Dennis Date: Mon, 28 Sep 2026 12:44:06 +0200 Subject: [PATCH 1/3] Add MCP server for browsing, commenting on and moving roadmap items Adds a laravel/mcp server at /mcp, authenticated with Sanctum tokens that users can create and revoke on their profile page. Tools respect the same visibility rules as the site, and moving items is limited to admins and employees. --- .claude/skills/mcp-development/SKILL.md | 107 ++ .../skills/mcp-development/references/app.md | 940 ++++++++++++++++++ app/Livewire/Profile.php | 52 + app/Mcp/Servers/RoadmapServer.php | 47 + app/Mcp/Tools/CommentOnItemTool.php | 94 ++ .../Tools/Concerns/InteractsWithRoadmap.php | 131 +++ app/Mcp/Tools/GetItemTool.php | 66 ++ app/Mcp/Tools/GetProjectTool.php | 64 ++ app/Mcp/Tools/ListItemsTool.php | 102 ++ app/Mcp/Tools/ListProjectsTool.php | 42 + app/Mcp/Tools/MoveItemTool.php | 87 ++ boost.json | 1 + composer.json | 1 + composer.lock | 150 +-- ...res_at_to_personal_access_tokens_table.php | 21 + lang/en/profile.php | 17 + resources/views/livewire/profile.blade.php | 42 + routes/ai.php | 6 + .../Feature/Livewire/ProfileMcpTokensTest.php | 47 + tests/Feature/Mcp/CommentOnItemToolTest.php | 124 +++ tests/Feature/Mcp/McpEndpointTest.php | 41 + tests/Feature/Mcp/MoveItemToolTest.php | 64 ++ tests/Feature/Mcp/ReadToolsTest.php | 140 +++ 23 files changed, 2311 insertions(+), 75 deletions(-) create mode 100644 .claude/skills/mcp-development/SKILL.md create mode 100644 .claude/skills/mcp-development/references/app.md create mode 100644 app/Mcp/Servers/RoadmapServer.php create mode 100644 app/Mcp/Tools/CommentOnItemTool.php create mode 100644 app/Mcp/Tools/Concerns/InteractsWithRoadmap.php create mode 100644 app/Mcp/Tools/GetItemTool.php create mode 100644 app/Mcp/Tools/GetProjectTool.php create mode 100644 app/Mcp/Tools/ListItemsTool.php create mode 100644 app/Mcp/Tools/ListProjectsTool.php create mode 100644 app/Mcp/Tools/MoveItemTool.php create mode 100644 database/migrations/2026_09_28_104311_add_expires_at_to_personal_access_tokens_table.php create mode 100644 routes/ai.php create mode 100644 tests/Feature/Livewire/ProfileMcpTokensTest.php create mode 100644 tests/Feature/Mcp/CommentOnItemToolTest.php create mode 100644 tests/Feature/Mcp/McpEndpointTest.php create mode 100644 tests/Feature/Mcp/MoveItemToolTest.php create mode 100644 tests/Feature/Mcp/ReadToolsTest.php diff --git a/.claude/skills/mcp-development/SKILL.md b/.claude/skills/mcp-development/SKILL.md new file mode 100644 index 00000000..ca63d9fb --- /dev/null +++ b/.claude/skills/mcp-development/SKILL.md @@ -0,0 +1,107 @@ +--- +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 +--- + +# 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`: + + +```php +use Laravel\Mcp\Facades\Mcp; + +Mcp::web('/mcp/demo', \App\Mcp\Servers\AppServer::class); +``` + +### Creating MCP Primitives + +```bash +php artisan make:mcp-tool ToolName # Create a tool +php artisan make:mcp-resource ResourceName # Create a resource +php artisan make:mcp-prompt PromptName # Create a prompt +php artisan make:mcp-server ServerName # Create a server +php artisan 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 + + +```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')); + } +} +``` + +### Registering Primitives in a 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, + ]; +} +``` + +## 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 diff --git a/.claude/skills/mcp-development/references/app.md b/.claude/skills/mcp-development/references/app.md new file mode 100644 index 00000000..40e3ef83 --- /dev/null +++ b/.claude/skills/mcp-development/references/app.md @@ -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.`), 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 + + + + + +
+

Dashboard 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 `capabilities.extensions["io.modelcontextprotocol/ui"]` on `server/discover` when any `AppResource` is registered. Add cases of the `Extension` enum to the server's `$extensions` property to declare any other extension. The client declares its own support in the `io.modelcontextprotocol/clientCapabilities` member of each request's `_meta`. + +--- + +## 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 `` 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 ` + + +
+ +

+
+ +``` + +**Props and slots:** + +| Name | Type | Description | +| ------------- | ------------- | ---------------------------------------------------- | +| `title` | Prop | Sets ``. 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 ?? "{}")); +}); +``` diff --git a/app/Livewire/Profile.php b/app/Livewire/Profile.php index 5c71df52..00e70b9f 100644 --- a/app/Livewire/Profile.php +++ b/app/Livewire/Profile.php @@ -48,6 +48,7 @@ class Profile extends Component implements HasForms, HasTable, HasActions public $notification_settings; public $date_locale; public $hide_from_leaderboard; + public ?string $newMcpToken = null; public User $user; public function mount(): void @@ -372,6 +373,56 @@ public function disableTwoFactorAction(): Action }); } + /** + * Create a personal access token for connecting an MCP client, the plain text token is only shown once. + */ + public function createMcpTokenAction(): Action + { + return Action::make('createMcpToken') + ->label(trans('profile.mcp.create_token')) + ->color(Color::Blue) + ->modalWidth('md') + ->schema([ + TextInput::make('name') + ->label(trans('profile.mcp.token_name')) + ->placeholder(trans('profile.mcp.token_name_placeholder')) + ->required() + ->maxLength(255), + ]) + ->action(function (array $data) { + $this->newMcpToken = $this->user->createToken($data['name'])->plainTextToken; + + Notification::make('mcp-token-created') + ->title(trans('profile.mcp.token_created_notification')) + ->success() + ->send(); + }); + } + + /** + * Revoke one of the user's own personal access tokens. + */ + public function revokeMcpTokenAction(): Action + { + return Action::make('revokeMcpToken') + ->label(trans('profile.mcp.revoke')) + ->color(Color::Red) + ->link() + ->requiresConfirmation() + ->modalAlignment(Alignment::Left) + ->modalDescription(trans('profile.mcp.revoke_confirmation')) + ->action(function (array $arguments) { + $this->user->tokens()->whereKey((int) ($arguments['token'] ?? 0))->delete(); + + $this->newMcpToken = null; + + Notification::make('mcp-token-revoked') + ->title(trans('profile.mcp.token_revoked_notification')) + ->success() + ->send(); + }); + } + public function getLocalesProperty(): array { $locales = ResourceBundle::getLocales(''); @@ -395,6 +446,7 @@ public function render() ? Fortify::currentEncrypter()->decrypt($this->user->two_factor_secret) : null, 'recoveryCodes' => $twoFactorConfirmed ? $this->user->recoveryCodes() : [], + 'mcpTokens' => $this->user->tokens()->latest()->get(), ]); } diff --git a/app/Mcp/Servers/RoadmapServer.php b/app/Mcp/Servers/RoadmapServer.php new file mode 100644 index 00000000..08b8b75f --- /dev/null +++ b/app/Mcp/Servers/RoadmapServer.php @@ -0,0 +1,47 @@ +<?php + +namespace App\Mcp\Servers; + +use Laravel\Mcp\Server; +use App\Mcp\Tools\GetItemTool; +use App\Mcp\Tools\MoveItemTool; +use App\Mcp\Tools\ListItemsTool; +use App\Mcp\Tools\GetProjectTool; +use App\Mcp\Tools\ListProjectsTool; +use App\Mcp\Tools\CommentOnItemTool; +use Laravel\Mcp\Server\Attributes\Name; +use Laravel\Mcp\Server\Attributes\Version; +use Laravel\Mcp\Server\Attributes\Instructions; + +#[Name('Roadmap')] +#[Version('1.0.0')] +#[Instructions(<<<'MARKDOWN' + This server gives access to a product roadmap. The roadmap is organised in projects, each project has boards + (statuses such as "Under review", "Planned" or "Done") and items (feature requests, ideas and bugs) live on a board. + + - Use `list-projects` to discover projects and their boards, and `get-project` for the item counts per board. + - Use `list-items` to browse or search items, and `get-item` to read an item with its comments. + - Use `comment-on-item` to comment on an item or reply to a comment. + - Admins and employees can use `move-item` to move an item to another board or project. + + Everything happens as the user that owns the API token, so only the projects and items that user can see are available. + MARKDOWN)] +class RoadmapServer extends Server +{ + protected array $tools = [ + ListProjectsTool::class, + GetProjectTool::class, + ListItemsTool::class, + GetItemTool::class, + CommentOnItemTool::class, + MoveItemTool::class, + ]; + + protected array $resources = [ + // + ]; + + protected array $prompts = [ + // + ]; +} diff --git a/app/Mcp/Tools/CommentOnItemTool.php b/app/Mcp/Tools/CommentOnItemTool.php new file mode 100644 index 00000000..9d925f63 --- /dev/null +++ b/app/Mcp/Tools/CommentOnItemTool.php @@ -0,0 +1,94 @@ +<?php + +namespace App\Mcp\Tools; + +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Tool; +use App\Rules\ProfanityCheck; +use App\Settings\GeneralSettings; +use Laravel\Mcp\Server\Attributes\Name; +use Laravel\Mcp\Server\Attributes\Description; +use Illuminate\Contracts\JsonSchema\JsonSchema; +use App\Mcp\Tools\Concerns\InteractsWithRoadmap; + +#[Name('comment-on-item')] +#[Description('Post a comment on a roadmap item, or reply to an existing comment. Mention users with @username. Admins and employees can post a private note that is only visible to other admins and employees.')] +class CommentOnItemTool extends Tool +{ + use InteractsWithRoadmap; + + public function handle(Request $request): Response + { + $validated = $request->validate([ + 'item' => ['required'], + 'content' => ['required', 'string', 'min:3', new ProfanityCheck()], + 'parent_id' => ['nullable', 'integer'], + 'private' => ['nullable', 'boolean'], + ]); + + $user = $this->currentUser(); + + if (! $user) { + return Response::error('You need to be logged in to comment.'); + } + + if (app(GeneralSettings::class)->users_must_verify_email && ! $user->hasVerifiedEmail()) { + return Response::error('You need to verify your email address before you can comment.'); + } + + $item = $this->findItem($validated['item']); + + if (! $item) { + return Response::error('Item not found.'); + } + + if ($item->board?->block_comments) { + return Response::error('Comments are disabled for items on this board.'); + } + + $private = (bool) ($validated['private'] ?? false); + + if ($private && ! $user->hasAdminAccess()) { + return Response::error('Only admins and employees can post private notes.'); + } + + $parent = null; + + if (filled($validated['parent_id'] ?? null)) { + $parent = $item->comments() + ->when(! $user->hasAdminAccess(), fn ($query) => $query->where('private', false)) + ->find($validated['parent_id']); + + if (! $parent) { + return Response::error('The comment you are replying to does not exist on this item.'); + } + } + + $comment = $item->comments()->create([ + 'content' => $validated['content'], + 'parent_id' => $parent?->id, + 'user_id' => $user->id, + 'private' => $private, + ]); + + return Response::json([ + 'message' => $comment->private ? 'Private note added.' : 'Comment added.', + 'comment' => $this->presentComment($comment->refresh()->load('user:id,name,username')), + 'url' => $item->view_url . '#comment-' . $comment->id, + ]); + } + + /** + * @return array<string, \Illuminate\JsonSchema\Types\Type> + */ + public function schema(JsonSchema $schema): array + { + return [ + 'item' => $schema->string()->description('The ID or slug of the item to comment on.')->required(), + 'content' => $schema->string()->min(3)->description('The comment, markdown is supported.')->required(), + 'parent_id' => $schema->integer()->description('The ID of the comment to reply to.'), + 'private' => $schema->boolean()->description('Post as a private note, only available to admins and employees.'), + ]; + } +} diff --git a/app/Mcp/Tools/Concerns/InteractsWithRoadmap.php b/app/Mcp/Tools/Concerns/InteractsWithRoadmap.php new file mode 100644 index 00000000..aeca71b0 --- /dev/null +++ b/app/Mcp/Tools/Concerns/InteractsWithRoadmap.php @@ -0,0 +1,131 @@ +<?php + +namespace App\Mcp\Tools\Concerns; + +use App\Models\Item; +use App\Models\User; +use App\Models\Board; +use App\Models\Comment; +use App\Models\Project; + +trait InteractsWithRoadmap +{ + /** + * Find a project the current user is allowed to see by its ID or slug. + */ + protected function findProject(int|string $identifier): ?Project + { + return Project::query() + ->visibleForCurrentUser() + ->where(fn ($query) => $this->whereIdentifier($query, 'projects', $identifier)) + ->first(); + } + + /** + * Find an item the current user is allowed to see by its ID or slug. + */ + protected function findItem(int|string $identifier): ?Item + { + return Item::query() + ->visibleForCurrentUser() + ->where(fn ($query) => $this->whereIdentifier($query, 'items', $identifier)) + ->first(); + } + + /** + * Find a board of the given project by its ID or slug, hidden boards are only available to admins and employees. + */ + protected function findBoard(Project $project, int|string $identifier): ?Board + { + return $project->boards() + ->when(! $this->currentUser()?->hasAdminAccess(), fn ($query) => $query->visible()) + ->where(fn ($query) => $this->whereIdentifier($query, 'boards', $identifier)) + ->first(); + } + + protected function currentUser(): ?User + { + return auth()->user(); + } + + /** + * @return array<string, mixed> + */ + protected function presentProject(Project $project): array + { + return [ + 'id' => $project->id, + 'slug' => $project->slug, + 'title' => $project->title, + 'group' => $project->group, + 'description' => $project->description, + 'private' => $project->private, + 'url' => route('projects.show', $project), + ]; + } + + /** + * @return array<string, mixed> + */ + protected function presentBoard(Board $board): array + { + return [ + 'id' => $board->id, + 'slug' => $board->slug, + 'title' => $board->title, + 'description' => $board->description, + 'visible' => $board->visible, + 'comments_blocked' => $board->block_comments, + ]; + } + + /** + * @return array<string, mixed> + */ + protected function presentItem(Item $item): array + { + return [ + 'id' => $item->id, + 'slug' => $item->slug, + 'title' => $item->title, + 'excerpt' => $item->excerpt, + 'project' => $item->project ? ['id' => $item->project->id, 'slug' => $item->project->slug, 'title' => $item->project->title] : null, + 'board' => $item->board ? ['id' => $item->board->id, 'slug' => $item->board->slug, 'title' => $item->board->title] : null, + 'votes' => (int) $item->total_votes, + 'pinned' => $item->pinned, + 'private' => $item->private, + 'tags' => $item->tags->pluck('name')->values()->all(), + 'url' => $item->view_url, + 'created_at' => $item->created_at?->toIso8601String(), + ]; + } + + /** + * @return array<string, mixed> + */ + protected function presentComment(Comment $comment): array + { + return [ + 'id' => $comment->id, + 'parent_id' => $comment->parent_id, + 'author' => $comment->user?->name, + 'author_username' => $comment->user?->username, + 'content' => $comment->content, + 'private' => $comment->private, + 'votes' => (int) $comment->total_votes, + 'created_at' => $comment->created_at?->toIso8601String(), + ]; + } + + /** + * Match a model on its ID when the identifier is numeric, and always on its slug. + */ + private function whereIdentifier($query, string $table, int|string $identifier): void + { + if (is_numeric($identifier)) { + $query->where("{$table}.id", (int) $identifier); + } + + $query->orWhere("{$table}.slug", (string) $identifier); + } +} diff --git a/app/Mcp/Tools/GetItemTool.php b/app/Mcp/Tools/GetItemTool.php new file mode 100644 index 00000000..8d7c0172 --- /dev/null +++ b/app/Mcp/Tools/GetItemTool.php @@ -0,0 +1,66 @@ +<?php + +namespace App\Mcp\Tools; + +use App\Models\Comment; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Attributes\Name; +use Laravel\Mcp\Server\Attributes\Description; +use Illuminate\Contracts\JsonSchema\JsonSchema; +use App\Mcp\Tools\Concerns\InteractsWithRoadmap; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent; + +#[Name('get-item')] +#[Description('Get a single roadmap item with its full (markdown) content and comments. Replies reference their parent comment through "parent_id".')] +#[IsReadOnly] +#[IsIdempotent] +class GetItemTool extends Tool +{ + use InteractsWithRoadmap; + + public function handle(Request $request): Response + { + $request->validate([ + 'item' => ['required'], + ]); + + $item = $this->findItem($request->get('item')); + + if (! $item) { + return Response::error('Item not found.'); + } + + $item->load(['project', 'board', 'tags', 'user:id,name,username', 'assignedUsers:id,name,username']); + + $comments = $item->comments() + ->with('user:id,name,username') + ->when(! $this->currentUser()?->hasAdminAccess(), fn ($query) => $query->where('private', false)) + ->oldest() + ->oldest('id') + ->get(); + + return Response::json([ + ...$this->presentItem($item), + 'content' => $item->content, + 'author' => $item->user?->name, + 'author_username' => $item->user?->username, + 'assigned_users' => $item->assignedUsers->pluck('name')->values()->all(), + 'comments_blocked' => (bool) $item->board?->block_comments, + 'updated_at' => $item->updated_at?->toIso8601String(), + 'comments' => $comments->map(fn (Comment $comment) => $this->presentComment($comment))->all(), + ]); + } + + /** + * @return array<string, \Illuminate\JsonSchema\Types\Type> + */ + public function schema(JsonSchema $schema): array + { + return [ + 'item' => $schema->string()->description('The ID or slug of the item.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetProjectTool.php b/app/Mcp/Tools/GetProjectTool.php new file mode 100644 index 00000000..1bcde71f --- /dev/null +++ b/app/Mcp/Tools/GetProjectTool.php @@ -0,0 +1,64 @@ +<?php + +namespace App\Mcp\Tools; + +use App\Models\Item; +use App\Models\Board; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Attributes\Name; +use Laravel\Mcp\Server\Attributes\Description; +use Illuminate\Contracts\JsonSchema\JsonSchema; +use App\Mcp\Tools\Concerns\InteractsWithRoadmap; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent; + +#[Name('get-project')] +#[Description('Get a single roadmap project with its boards and the number of items on each board.')] +#[IsReadOnly] +#[IsIdempotent] +class GetProjectTool extends Tool +{ + use InteractsWithRoadmap; + + public function handle(Request $request): Response + { + $request->validate([ + 'project' => ['required'], + ]); + + $project = $this->findProject($request->get('project')); + + if (! $project) { + return Response::error('Project not found.'); + } + + $boards = $this->currentUser()?->hasAdminAccess() ? $project->boards : $project->boards()->visible()->get(); + + $itemCounts = Item::query() + ->visibleForCurrentUser() + ->where('items.project_id', $project->id) + ->selectRaw('board_id, count(*) as aggregate') + ->groupBy('board_id') + ->pluck('aggregate', 'board_id'); + + return Response::json([ + ...$this->presentProject($project), + 'boards' => $boards->map(fn (Board $board) => [ + ...$this->presentBoard($board), + 'items_count' => (int) $itemCounts->get($board->id, 0), + ])->values()->all(), + ]); + } + + /** + * @return array<string, \Illuminate\JsonSchema\Types\Type> + */ + public function schema(JsonSchema $schema): array + { + return [ + 'project' => $schema->string()->description('The ID or slug of the project.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListItemsTool.php b/app/Mcp/Tools/ListItemsTool.php new file mode 100644 index 00000000..06fe8875 --- /dev/null +++ b/app/Mcp/Tools/ListItemsTool.php @@ -0,0 +1,102 @@ +<?php + +namespace App\Mcp\Tools; + +use App\Models\Item; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Tool; +use Illuminate\Validation\Rule; +use Laravel\Mcp\Server\Attributes\Name; +use Laravel\Mcp\Server\Attributes\Description; +use Illuminate\Contracts\JsonSchema\JsonSchema; +use App\Mcp\Tools\Concerns\InteractsWithRoadmap; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent; + +#[Name('list-items')] +#[Description('List or search roadmap items (feature requests, ideas, bugs). Optionally filter on a project and board, and sort by newest, most votes or latest comment.')] +#[IsReadOnly] +#[IsIdempotent] +class ListItemsTool extends Tool +{ + use InteractsWithRoadmap; + + public function handle(Request $request): Response + { + $validated = $request->validate([ + 'project' => ['nullable', 'required_with:board'], + 'board' => ['nullable'], + 'search' => ['nullable', 'string', 'max:255'], + 'sort' => ['nullable', Rule::in(['latest', 'popular', 'last_commented'])], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], + 'page' => ['nullable', 'integer', 'min:1'], + ], [ + 'project.required_with' => 'A board can only be used together with a project.', + ]); + + $query = Item::query() + ->visibleForCurrentUser() + ->with(['project', 'board', 'tags']); + + if (filled($validated['project'] ?? null)) { + $project = $this->findProject($validated['project']); + + if (! $project) { + return Response::error('Project not found.'); + } + + $query->where('items.project_id', $project->id); + + if (filled($validated['board'] ?? null)) { + $board = $this->findBoard($project, $validated['board']); + + if (! $board) { + return Response::error('Board not found in this project.'); + } + + $query->where('items.board_id', $board->id); + } + } + + if (filled($validated['search'] ?? null)) { + $query->where(fn ($query) => $query + ->where('items.title', 'like', '%' . $validated['search'] . '%') + ->orWhere('items.content', 'like', '%' . $validated['search'] . '%')); + } + + match ($validated['sort'] ?? 'latest') { + 'popular' => $query->orderByDesc('items.total_votes'), + 'last_commented' => $query->withMax('comments', 'created_at')->orderByDesc('comments_max_created_at'), + default => $query->latest('items.created_at'), + }; + + $items = $query->orderByDesc('items.id')->paginate( + perPage: $validated['per_page'] ?? 20, + page: $validated['page'] ?? 1, + ); + + return Response::json([ + 'items' => collect($items->items())->map(fn (Item $item) => $this->presentItem($item))->all(), + 'page' => $items->currentPage(), + 'per_page' => $items->perPage(), + 'total' => $items->total(), + 'has_more_pages' => $items->hasMorePages(), + ]); + } + + /** + * @return array<string, \Illuminate\JsonSchema\Types\Type> + */ + public function schema(JsonSchema $schema): array + { + return [ + 'project' => $schema->string()->description('Only list items of this project (ID or slug).'), + 'board' => $schema->string()->description('Only list items on this board (ID or slug), requires a project.'), + 'search' => $schema->string()->description('Search the title and content of items.'), + 'sort' => $schema->string()->enum(['latest', 'popular', 'last_commented'])->description('Sort order, defaults to latest.'), + 'per_page' => $schema->integer()->min(1)->max(50)->description('Number of items per page, defaults to 20.'), + 'page' => $schema->integer()->min(1)->description('The page to fetch, defaults to 1.'), + ]; + } +} diff --git a/app/Mcp/Tools/ListProjectsTool.php b/app/Mcp/Tools/ListProjectsTool.php new file mode 100644 index 00000000..73281c52 --- /dev/null +++ b/app/Mcp/Tools/ListProjectsTool.php @@ -0,0 +1,42 @@ +<?php + +namespace App\Mcp\Tools; + +use App\Models\Board; +use App\Models\Project; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Attributes\Name; +use Laravel\Mcp\Server\Attributes\Description; +use App\Mcp\Tools\Concerns\InteractsWithRoadmap; +use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; +use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent; + +#[Name('list-projects')] +#[Description('List all roadmap projects you have access to, including the boards (statuses) of each project.')] +#[IsReadOnly] +#[IsIdempotent] +class ListProjectsTool extends Tool +{ + use InteractsWithRoadmap; + + public function handle(Request $request): Response + { + $showHiddenBoards = (bool) $this->currentUser()?->hasAdminAccess(); + + $projects = Project::query() + ->visibleForCurrentUser() + ->with(['boards' => fn ($query) => $query->when(! $showHiddenBoards, fn ($query) => $query->visible())]) + ->orderBy('sort_order') + ->orderBy('title') + ->get(); + + return Response::json([ + 'projects' => $projects->map(fn (Project $project) => [ + ...$this->presentProject($project), + 'boards' => $project->boards->map(fn (Board $board) => $this->presentBoard($board))->values()->all(), + ])->values()->all(), + ]); + } +} diff --git a/app/Mcp/Tools/MoveItemTool.php b/app/Mcp/Tools/MoveItemTool.php new file mode 100644 index 00000000..b83edfa8 --- /dev/null +++ b/app/Mcp/Tools/MoveItemTool.php @@ -0,0 +1,87 @@ +<?php + +namespace App\Mcp\Tools; + +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Tool; +use Laravel\Mcp\Server\Attributes\Name; +use Laravel\Mcp\Server\Attributes\Description; +use Illuminate\Contracts\JsonSchema\JsonSchema; +use App\Mcp\Tools\Concerns\InteractsWithRoadmap; +use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent; + +#[Name('move-item')] +#[Description('Move a roadmap item to another board, optionally in another project. Only available to admins and employees.')] +#[IsIdempotent] +class MoveItemTool extends Tool +{ + use InteractsWithRoadmap; + + public function handle(Request $request): Response + { + if (! $this->currentUser()?->hasAdminAccess()) { + return Response::error('You are not allowed to move items.'); + } + + $validated = $request->validate([ + 'item' => ['required'], + 'board' => ['required'], + 'project' => ['nullable'], + ]); + + $item = $this->findItem($validated['item']); + + if (! $item) { + return Response::error('Item not found.'); + } + + $project = filled($validated['project'] ?? null) ? $this->findProject($validated['project']) : $item->project; + + if (! $project) { + return Response::error(filled($validated['project'] ?? null) + ? 'Project not found.' + : 'This item does not belong to a project yet, pass the project to move it to.'); + } + + $board = $this->findBoard($project, $validated['board']); + + if (! $board) { + return Response::error("Board not found in project \"{$project->title}\"."); + } + + if ($item->board_id === $board->id && $item->project_id === $project->id) { + return Response::text("\"{$item->title}\" is already on board \"{$board->title}\" in project \"{$project->title}\"."); + } + + $item->update([ + 'project_id' => $project->id, + 'board_id' => $board->id, + ]); + + return Response::json([ + 'message' => "Moved \"{$item->title}\" to board \"{$board->title}\" in project \"{$project->title}\".", + 'item' => $this->presentItem($item->refresh()->load(['project', 'board', 'tags'])), + ]); + } + + /** + * Only admins and employees can move items, so there is no need to offer this tool to other users. + */ + public function shouldRegister(Request $request): bool + { + return (bool) $request->user()?->hasAdminAccess(); + } + + /** + * @return array<string, \Illuminate\JsonSchema\Types\Type> + */ + public function schema(JsonSchema $schema): array + { + return [ + 'item' => $schema->string()->description('The ID or slug of the item to move.')->required(), + 'board' => $schema->string()->description('The ID or slug of the board to move the item to.')->required(), + 'project' => $schema->string()->description('The ID or slug of the project the board belongs to, defaults to the current project of the item.'), + ]; + } +} diff --git a/boost.json b/boost.json index 191eb099..03c7c548 100644 --- a/boost.json +++ b/boost.json @@ -15,6 +15,7 @@ "fortify-development", "laravel-best-practices", "testing-best-practices", + "mcp-development", "octane-development", "socialite-development", "tailwindcss-development", diff --git a/composer.json b/composer.json index 9ccfc759..f258f9f2 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "lara-zeus/spatie-translatable": "^1.0", "laravel/fortify": "*", "laravel/framework": "^12.0", + "laravel/mcp": "^1.0", "laravel/octane": "^2.9", "laravel/sanctum": "^4.0", "laravel/socialite": "^5.6.1", diff --git a/composer.lock b/composer.lock index 711c5d24..db73d8bc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c08074bcfbef550c4173af263c2f5bc2", + "content-hash": "bb73bece6facd2ad3cd4fbd31979a7d8", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -3893,6 +3893,80 @@ }, "time": "2026-09-08T14:29:09+00:00" }, + { + "name": "laravel/mcp", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "92987a9d03847801299ff4abe909ba7686147dde" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/92987a9d03847801299ff4abe909ba7686147dde", + "reference": "92987a9d03847801299ff4abe909ba7686147dde", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-09-24T10:45:09+00:00" + }, { "name": "laravel/octane", "version": "v2.20.0", @@ -16568,80 +16642,6 @@ }, "time": "2026-09-23T09:33:31+00:00" }, - { - "name": "laravel/mcp", - "version": "v1.0.1", - "source": { - "type": "git", - "url": "https://github.com/laravel/mcp.git", - "reference": "92987a9d03847801299ff4abe909ba7686147dde" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/92987a9d03847801299ff4abe909ba7686147dde", - "reference": "92987a9d03847801299ff4abe909ba7686147dde", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-mbstring": "*", - "illuminate/console": "^11.45.3|^12.41.1|^13.0", - "illuminate/container": "^11.45.3|^12.41.1|^13.0", - "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", - "illuminate/http": "^11.45.3|^12.41.1|^13.0", - "illuminate/json-schema": "^12.41.1|^13.0", - "illuminate/routing": "^11.45.3|^12.41.1|^13.0", - "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "illuminate/validation": "^11.45.3|^12.41.1|^13.0", - "php": "^8.2", - "symfony/process": "^7.4.5|^8.0.5" - }, - "require-dev": { - "laravel/pint": "^1.20", - "orchestra/testbench": "^9.15|^10.8|^11.0", - "pestphp/pest": "^3.8.5|^4.3.2", - "phpstan/phpstan": "^2.1.27", - "rector/rector": "^2.2.4" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Mcp": "Laravel\\Mcp\\Facades\\Mcp" - }, - "providers": [ - "Laravel\\Mcp\\Server\\McpServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Mcp\\": "src/", - "Laravel\\Mcp\\Server\\": "src/Server/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Rapidly build MCP servers for your Laravel applications.", - "homepage": "https://github.com/laravel/mcp", - "keywords": [ - "laravel", - "mcp" - ], - "support": { - "issues": "https://github.com/laravel/mcp/issues", - "source": "https://github.com/laravel/mcp" - }, - "time": "2026-09-24T10:45:09+00:00" - }, { "name": "laravel/roster", "version": "v1.0.0", diff --git a/database/migrations/2026_09_28_104311_add_expires_at_to_personal_access_tokens_table.php b/database/migrations/2026_09_28_104311_add_expires_at_to_personal_access_tokens_table.php new file mode 100644 index 00000000..ef37de08 --- /dev/null +++ b/database/migrations/2026_09_28_104311_add_expires_at_to_personal_access_tokens_table.php @@ -0,0 +1,21 @@ +<?php + +use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Migrations\Migration; + +return new class extends Migration { + public function up(): void + { + Schema::table('personal_access_tokens', function (Blueprint $table) { + $table->timestamp('expires_at')->nullable()->after('last_used_at'); + }); + } + + public function down(): void + { + Schema::table('personal_access_tokens', function (Blueprint $table) { + $table->dropColumn('expires_at'); + }); + } +}; diff --git a/lang/en/profile.php b/lang/en/profile.php index 54f2b5ab..57c3f246 100644 --- a/lang/en/profile.php +++ b/lang/en/profile.php @@ -48,4 +48,21 @@ 'disabled_notification' => 'Two-factor authentication has been disabled.', 'codes_regenerated_notification' => 'New recovery codes have been generated.', ], + + 'mcp' => [ + 'heading' => 'MCP access', + 'description' => 'Connect AI assistants such as Claude or Cursor to the roadmap through MCP. Create a token and send it as a bearer token to the endpoint below. The assistant can do everything you can do on the roadmap.', + 'endpoint' => 'Endpoint', + 'create_token' => 'Create token', + 'token_name' => 'Token name', + 'token_name_placeholder' => 'Claude on my laptop', + 'new_token' => 'Copy your new token now, it won\'t be shown again.', + 'no_tokens' => 'You have not created any tokens yet.', + 'last_used' => 'Last used :date', + 'never_used' => 'Never used', + 'revoke' => 'Revoke', + 'revoke_confirmation' => 'Clients using this token will lose access to the roadmap.', + 'token_created_notification' => 'Token created.', + 'token_revoked_notification' => 'Token revoked.', + ], ]; diff --git a/resources/views/livewire/profile.blade.php b/resources/views/livewire/profile.blade.php index c6859b73..4dc875d9 100644 --- a/resources/views/livewire/profile.blade.php +++ b/resources/views/livewire/profile.blade.php @@ -87,6 +87,48 @@ class="grid grid-cols-2 gap-2 rounded-lg bg-gray-50 p-4 font-mono text-sm dark:b @endif </div> + {{-- MCP access tokens --}} + <div class="space-y-4 border-t border-gray-200 dark:border-white/10 pt-6"> + <h2 class="text-lg tracking-tight font-bold">{{ trans('profile.mcp.heading') }}</h2> + + <p class="text-gray-500 text-sm max-w-2xl">{{ trans('profile.mcp.description') }}</p> + + <div class="text-sm text-gray-500"> + {{ trans('profile.mcp.endpoint') }}: + <code class="select-all font-mono text-gray-700 dark:text-gray-300">{{ url('mcp') }}</code> + </div> + + @if($newMcpToken) + <div class="space-y-2 rounded-lg bg-gray-50 p-4 dark:bg-gray-900 max-w-2xl"> + <p class="text-sm font-medium">{{ trans('profile.mcp.new_token') }}</p> + <code class="block break-all select-all font-mono text-sm text-gray-700 dark:text-gray-300">{{ $newMcpToken }}</code> + </div> + @endif + + @if($mcpTokens->isEmpty()) + <p class="text-sm text-gray-500">{{ trans('profile.mcp.no_tokens') }}</p> + @else + <ul class="divide-y divide-gray-200 dark:divide-white/10 max-w-2xl"> + @foreach($mcpTokens as $token) + <li class="flex items-center justify-between gap-4 py-2" wire:key="mcp-token-{{ $token->id }}"> + <div class="min-w-0"> + <p class="text-sm font-medium truncate">{{ $token->name }}</p> + <p class="text-xs text-gray-500"> + {{ $token->last_used_at ? trans('profile.mcp.last_used', ['date' => $token->last_used_at->diffForHumans()]) : trans('profile.mcp.never_used') }} + </p> + </div> + + {{ ($this->revokeMcpTokenAction)(['token' => $token->id]) }} + </li> + @endforeach + </ul> + @endif + + <div> + {{ $this->createMcpTokenAction }} + </div> + </div> + @if($hasSsoLoginAvailable) <div> <h2 class="text-lg tracking-tight font-bold">{{ trans('profile.social-login') }}</h2> diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 00000000..da873451 --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,6 @@ +<?php + +use Laravel\Mcp\Facades\Mcp; +use App\Mcp\Servers\RoadmapServer; + +Mcp::web('/mcp', RoadmapServer::class)->middleware(['auth:sanctum', 'throttle:api']); diff --git a/tests/Feature/Livewire/ProfileMcpTokensTest.php b/tests/Feature/Livewire/ProfileMcpTokensTest.php new file mode 100644 index 00000000..acb6f23c --- /dev/null +++ b/tests/Feature/Livewire/ProfileMcpTokensTest.php @@ -0,0 +1,47 @@ +<?php + +use App\Models\User; +use Livewire\Livewire; +use App\Livewire\Profile; +use Laravel\Sanctum\PersonalAccessToken; +use function Pest\Laravel\assertDatabaseHas; +use function Pest\Laravel\assertDatabaseMissing; + +it('creates an mcp token and shows it once', function () { + $user = createAndLoginUser(); + + $component = Livewire::test(Profile::class) + ->callAction('createMcpToken', ['name' => 'Claude']) + ->assertHasNoActionErrors(); + + expect($component->get('newMcpToken'))->not->toBeNull(); + + $component->assertSee($component->get('newMcpToken')); + + assertDatabaseHas(PersonalAccessToken::class, [ + 'tokenable_id' => $user->id, + 'tokenable_type' => User::class, + 'name' => 'Claude', + ]); +}); + +it('revokes an mcp token', function () { + $user = createAndLoginUser(); + $token = $user->createToken('Claude')->accessToken; + + Livewire::test(Profile::class) + ->assertSee('Claude') + ->callAction('revokeMcpToken', arguments: ['token' => $token->id]); + + assertDatabaseMissing(PersonalAccessToken::class, ['id' => $token->id]); +}); + +it('does not revoke tokens of other users', function () { + createAndLoginUser(); + $token = User::factory()->create()->createToken('Someone else')->accessToken; + + Livewire::test(Profile::class) + ->callAction('revokeMcpToken', arguments: ['token' => $token->id]); + + assertDatabaseHas(PersonalAccessToken::class, ['id' => $token->id]); +}); diff --git a/tests/Feature/Mcp/CommentOnItemToolTest.php b/tests/Feature/Mcp/CommentOnItemToolTest.php new file mode 100644 index 00000000..94de8d27 --- /dev/null +++ b/tests/Feature/Mcp/CommentOnItemToolTest.php @@ -0,0 +1,124 @@ +<?php + +use App\Models\Item; +use App\Models\User; +use App\Models\Board; +use App\Enums\UserRole; +use App\Models\Comment; +use App\Models\Project; +use App\Settings\GeneralSettings; +use App\Mcp\Servers\RoadmapServer; +use App\Mcp\Tools\CommentOnItemTool; +use function Pest\Laravel\assertDatabaseHas; +use function Pest\Laravel\assertDatabaseCount; + +it('comments on an item as the authenticated user', function () { + $user = User::factory()->create(); + $item = Item::factory()->create(); + + RoadmapServer::actingAs($user) + ->tool(CommentOnItemTool::class, ['item' => $item->slug, 'content' => 'This would be great!']) + ->assertOk() + ->assertSee(['Comment added.', 'This would be great!']); + + assertDatabaseHas(Comment::class, [ + 'item_id' => $item->id, + 'user_id' => $user->id, + 'content' => 'This would be great!', + 'private' => false, + ]); +}); + +it('replies to a comment on the same item', function () { + $item = Item::factory()->create(); + $parent = Comment::factory()->for($item)->for(User::factory())->create(); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(CommentOnItemTool::class, ['item' => $item->id, 'content' => 'I agree', 'parent_id' => $parent->id]) + ->assertOk(); + + assertDatabaseHas(Comment::class, ['item_id' => $item->id, 'parent_id' => $parent->id, 'content' => 'I agree']); +}); + +it('does not reply to a comment of another item', function () { + $item = Item::factory()->create(); + $otherComment = Comment::factory()->for(Item::factory())->for(User::factory())->create(); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(CommentOnItemTool::class, ['item' => $item->id, 'content' => 'I agree', 'parent_id' => $otherComment->id]) + ->assertHasErrors(['The comment you are replying to does not exist on this item.']); + + assertDatabaseCount(Comment::class, 1); +}); + +it('does not let regular users reply to private notes', function () { + $item = Item::factory()->create(); + $privateNote = Comment::factory()->for($item)->for(User::factory())->create(['private' => true]); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(CommentOnItemTool::class, ['item' => $item->id, 'content' => 'I agree', 'parent_id' => $privateNote->id]) + ->assertHasErrors(['The comment you are replying to does not exist on this item.']); +}); + +it('only lets admins and employees post private notes', function (UserRole $role, bool $allowed) { + $item = Item::factory()->create(); + + $response = RoadmapServer::actingAs(User::factory()->create(['role' => $role])) + ->tool(CommentOnItemTool::class, ['item' => $item->id, 'content' => 'Internal note', 'private' => true]); + + if ($allowed) { + $response->assertOk()->assertSee('Private note added.'); + assertDatabaseHas(Comment::class, ['item_id' => $item->id, 'content' => 'Internal note', 'private' => true]); + } else { + $response->assertHasErrors(['Only admins and employees can post private notes.']); + assertDatabaseCount(Comment::class, 0); + } +})->with([ + [UserRole::User, false], + [UserRole::Employee, true], + [UserRole::Admin, true], +]); + +it('does not comment on items the user cannot see', function () { + $item = Item::factory()->private()->create(); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(CommentOnItemTool::class, ['item' => $item->id, 'content' => 'Sneaky comment']) + ->assertHasErrors(['Item not found.']); + + assertDatabaseCount(Comment::class, 0); +}); + +it('does not comment when comments are blocked on the board', function () { + $project = Project::factory()->create(); + $board = Board::factory()->for($project)->create(['block_comments' => true]); + $item = Item::factory()->for($project)->for($board)->create(); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(CommentOnItemTool::class, ['item' => $item->id, 'content' => 'Blocked comment']) + ->assertHasErrors(['Comments are disabled for items on this board.']); + + assertDatabaseCount(Comment::class, 0); +}); + +it('requires a verified email when the setting is enabled', function () { + GeneralSettings::fake(['users_must_verify_email' => true]); + + $item = Item::factory()->create(); + + RoadmapServer::actingAs(User::factory()->unverified()->create()) + ->tool(CommentOnItemTool::class, ['item' => $item->id, 'content' => 'Unverified comment']) + ->assertHasErrors(['You need to verify your email address before you can comment.']); + + assertDatabaseCount(Comment::class, 0); +}); + +it('validates the comment content', function () { + $item = Item::factory()->create(); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(CommentOnItemTool::class, ['item' => $item->id, 'content' => 'Hi']) + ->assertHasErrors(); + + assertDatabaseCount(Comment::class, 0); +}); diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php new file mode 100644 index 00000000..8222db65 --- /dev/null +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -0,0 +1,41 @@ +<?php + +use App\Models\User; +use App\Enums\UserRole; +use function Pest\Laravel\postJson; +use function Pest\Laravel\withToken; + +function listToolsRequest(): array +{ + return ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => []]; +} + +it('requires an api token', function () { + postJson('/mcp', listToolsRequest())->assertUnauthorized(); +}); + +it('rejects an invalid api token', function () { + withToken('invalid-token')->postJson('/mcp', listToolsRequest())->assertUnauthorized(); +}); + +it('lists the tools for a regular user without the move tool', function () { + $token = User::factory()->create(['role' => UserRole::User])->createToken('MCP')->plainTextToken; + + $tools = withToken($token)->postJson('/mcp', listToolsRequest()) + ->assertOk() + ->json('result.tools.*.name'); + + expect($tools) + ->toContain('list-projects', 'get-project', 'list-items', 'get-item', 'comment-on-item') + ->not->toContain('move-item'); +}); + +it('lists the move tool for employees', function () { + $token = User::factory()->create(['role' => UserRole::Employee])->createToken('MCP')->plainTextToken; + + $tools = withToken($token)->postJson('/mcp', listToolsRequest()) + ->assertOk() + ->json('result.tools.*.name'); + + expect($tools)->toContain('move-item'); +}); diff --git a/tests/Feature/Mcp/MoveItemToolTest.php b/tests/Feature/Mcp/MoveItemToolTest.php new file mode 100644 index 00000000..1bb6e4da --- /dev/null +++ b/tests/Feature/Mcp/MoveItemToolTest.php @@ -0,0 +1,64 @@ +<?php + +use App\Models\Item; +use App\Models\User; +use App\Models\Board; +use App\Enums\UserRole; +use App\Models\Project; +use App\Mcp\Tools\MoveItemTool; +use App\Mcp\Servers\RoadmapServer; +use function Pest\Laravel\assertDatabaseHas; + +it('moves an item to another board in the same project', function (UserRole $role) { + $project = Project::factory()->create(); + $board = Board::factory()->for($project)->create(); + $newBoard = Board::factory()->for($project)->create(['title' => 'In progress']); + $item = Item::factory()->for($project)->for($board)->create(); + + RoadmapServer::actingAs(User::factory()->create(['role' => $role])) + ->tool(MoveItemTool::class, ['item' => $item->id, 'board' => $newBoard->slug]) + ->assertOk() + ->assertSee('In progress'); + + assertDatabaseHas(Item::class, ['id' => $item->id, 'project_id' => $project->id, 'board_id' => $newBoard->id]); +})->with([UserRole::Admin, UserRole::Employee]); + +it('moves an item to a board in another project', function () { + $project = Project::factory()->create(); + $board = Board::factory()->for($project)->create(); + $otherProject = Project::factory()->create(); + $otherBoard = Board::factory()->for($otherProject)->create(); + $item = Item::factory()->for($project)->for($board)->create(); + + RoadmapServer::actingAs(User::factory()->admin()->create()) + ->tool(MoveItemTool::class, ['item' => $item->id, 'board' => $otherBoard->id, 'project' => $otherProject->slug]) + ->assertOk(); + + assertDatabaseHas(Item::class, ['id' => $item->id, 'project_id' => $otherProject->id, 'board_id' => $otherBoard->id]); +}); + +it('does not move an item to a board of another project', function () { + $project = Project::factory()->create(); + $board = Board::factory()->for($project)->create(); + $otherBoard = Board::factory()->for(Project::factory())->create(); + $item = Item::factory()->for($project)->for($board)->create(); + + RoadmapServer::actingAs(User::factory()->admin()->create()) + ->tool(MoveItemTool::class, ['item' => $item->id, 'board' => $otherBoard->id]) + ->assertHasErrors(); + + assertDatabaseHas(Item::class, ['id' => $item->id, 'board_id' => $board->id]); +}); + +it('does not let regular users move items', function () { + $project = Project::factory()->create(); + $board = Board::factory()->for($project)->create(); + $newBoard = Board::factory()->for($project)->create(); + $item = Item::factory()->for($project)->for($board)->create(); + + RoadmapServer::actingAs(User::factory()->create(['role' => UserRole::User])) + ->tool(MoveItemTool::class, ['item' => $item->id, 'board' => $newBoard->id]) + ->assertHasErrors(); + + assertDatabaseHas(Item::class, ['id' => $item->id, 'board_id' => $board->id]); +}); diff --git a/tests/Feature/Mcp/ReadToolsTest.php b/tests/Feature/Mcp/ReadToolsTest.php new file mode 100644 index 00000000..603c5c47 --- /dev/null +++ b/tests/Feature/Mcp/ReadToolsTest.php @@ -0,0 +1,140 @@ +<?php + +use App\Models\Item; +use App\Models\User; +use App\Models\Board; +use App\Enums\UserRole; +use App\Models\Comment; +use App\Models\Project; +use App\Mcp\Tools\GetItemTool; +use App\Mcp\Tools\ListItemsTool; +use App\Mcp\Tools\GetProjectTool; +use App\Mcp\Servers\RoadmapServer; +use App\Mcp\Tools\ListProjectsTool; + +it('lists the projects and visible boards the user can see', function () { + $project = Project::factory()->create(['title' => 'Public project']); + Board::factory()->for($project)->create(['title' => 'Planned board']); + Board::factory()->for($project)->create(['title' => 'Hidden board', 'visible' => false]); + Project::factory()->private()->create(['title' => 'Secret project']); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(ListProjectsTool::class) + ->assertOk() + ->assertSee(['Public project', 'Planned board']) + ->assertDontSee(['Secret project', 'Hidden board']); +}); + +it('lists private projects the user is a member of', function () { + $user = User::factory()->create(); + $project = Project::factory()->private()->create(['title' => 'Members only']); + $project->members()->attach($user); + + RoadmapServer::actingAs($user) + ->tool(ListProjectsTool::class) + ->assertOk() + ->assertSee('Members only'); +}); + +it('shows hidden boards and private projects to admins', function () { + $project = Project::factory()->private()->create(['title' => 'Secret project']); + Board::factory()->for($project)->create(['title' => 'Hidden board', 'visible' => false]); + + RoadmapServer::actingAs(User::factory()->admin()->create()) + ->tool(ListProjectsTool::class) + ->assertOk() + ->assertSee(['Secret project', 'Hidden board']); +}); + +it('gets a project by slug with item counts per board', function () { + $project = Project::factory()->create(['title' => 'Roadmap']); + $board = Board::factory()->for($project)->create(['title' => 'Planned']); + Item::factory()->count(3)->for($project)->for($board)->create(); + Item::factory()->private()->for($project)->for($board)->create(); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(GetProjectTool::class, ['project' => $project->slug]) + ->assertOk() + ->assertSee(['Roadmap', 'Planned', '"items_count":3']); +}); + +it('does not get a private project the user is not a member of', function () { + $project = Project::factory()->private()->create(); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(GetProjectTool::class, ['project' => $project->id]) + ->assertHasErrors(['Project not found.']); +}); + +it('does not let a member of one private project reach another private project', function () { + $user = User::factory()->create(); + Project::factory()->private()->create()->members()->attach($user); + $otherProject = Project::factory()->private()->create(); + + RoadmapServer::actingAs($user) + ->tool(GetProjectTool::class, ['project' => $otherProject->slug]) + ->assertHasErrors(['Project not found.']); +}); + +it('lists items filtered on project, board and search', function () { + $project = Project::factory()->create(); + $board = Board::factory()->for($project)->create(); + $otherBoard = Board::factory()->for($project)->create(); + Item::factory()->for($project)->for($board)->create(['title' => 'Dark mode support']); + Item::factory()->for($project)->for($board)->create(['title' => 'Export to CSV']); + Item::factory()->for($project)->for($otherBoard)->create(['title' => 'Dark theme for emails']); + Item::factory()->private()->for($project)->for($board)->create(['title' => 'Dark secret']); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(ListItemsTool::class, ['project' => $project->slug, 'board' => $board->id, 'search' => 'Dark']) + ->assertOk() + ->assertSee(['Dark mode support', '"total":1']) + ->assertDontSee(['Export to CSV', 'Dark theme for emails', 'Dark secret']); +}); + +it('sorts items by votes', function () { + Item::factory()->create(['title' => 'Least wanted', 'total_votes' => 1]); + Item::factory()->create(['title' => 'Most wanted', 'total_votes' => 50]); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(ListItemsTool::class, ['sort' => 'popular', 'per_page' => 1]) + ->assertOk() + ->assertSee(['Most wanted', '"has_more_pages":true']) + ->assertDontSee('Least wanted'); +}); + +it('requires a project when filtering on a board', function () { + RoadmapServer::actingAs(User::factory()->create()) + ->tool(ListItemsTool::class, ['board' => 1]) + ->assertHasErrors(['A board can only be used together with a project.']); +}); + +it('gets an item with its public comments', function () { + $item = Item::factory()->create(['title' => 'Dark mode', 'content' => 'Please add a dark mode.']); + Comment::factory()->for($item)->for(User::factory())->create(['content' => 'Public comment']); + Comment::factory()->for($item)->for(User::factory())->create(['content' => 'Private note', 'private' => true]); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(GetItemTool::class, ['item' => $item->slug]) + ->assertOk() + ->assertSee(['Dark mode', 'Please add a dark mode.', 'Public comment']) + ->assertDontSee('Private note'); +}); + +it('shows private notes on an item to employees', function () { + $item = Item::factory()->create(); + Comment::factory()->for($item)->for(User::factory())->create(['content' => 'Private note', 'private' => true]); + + RoadmapServer::actingAs(User::factory()->create(['role' => UserRole::Employee])) + ->tool(GetItemTool::class, ['item' => $item->id]) + ->assertOk() + ->assertSee('Private note'); +}); + +it('does not get a private item for regular users', function () { + $item = Item::factory()->private()->create(); + + RoadmapServer::actingAs(User::factory()->create()) + ->tool(GetItemTool::class, ['item' => $item->id]) + ->assertHasErrors(['Item not found.']); +}); From 994e3f4511252855dca73007284ca4d3e98419cc Mon Sep 17 00:00:00 2001 From: Dennis <dennissmink@gmail.com> Date: Mon, 28 Sep 2026 12:48:36 +0200 Subject: [PATCH 2/3] Add MCP docs page and let admins enable or disable MCP MCP is off by default. Admins enable it under Settings > MCP. While it's off the endpoint returns a 404 and the profile token section is hidden. The new /mcp/docs page explains how to create a token and connect a client, and lists the available tools. --- app/Filament/Pages/Settings.php | 11 +++ app/Http/Controllers/McpController.php | 26 ++++++ app/Http/Middleware/EnsureMcpIsEnabled.php | 23 +++++ app/Livewire/Profile.php | 3 + app/Mcp/Servers/RoadmapServer.php | 9 +- app/Settings/GeneralSettings.php | 1 + .../2026_09_28_104542_add_mcp_settings.php | 10 ++ lang/en/mcp.php | 26 ++++++ lang/en/profile.php | 3 +- lang/en/settings.php | 5 + resources/views/livewire/profile.blade.php | 75 ++++++++------- resources/views/mcp.blade.php | 93 +++++++++++++++++++ routes/ai.php | 3 +- routes/web.php | 1 + .../Feature/Controllers/McpControllerTest.php | 47 ++++++++++ tests/Feature/Filament/McpSettingsTest.php | 21 +++++ .../Feature/Livewire/ProfileMcpTokensTest.php | 13 +++ tests/Feature/Mcp/McpEndpointTest.php | 11 +++ 18 files changed, 343 insertions(+), 38 deletions(-) create mode 100644 app/Http/Controllers/McpController.php create mode 100644 app/Http/Middleware/EnsureMcpIsEnabled.php create mode 100644 database/settings/2026_09_28_104542_add_mcp_settings.php create mode 100644 lang/en/mcp.php create mode 100644 resources/views/mcp.blade.php create mode 100644 tests/Feature/Controllers/McpControllerTest.php create mode 100644 tests/Feature/Filament/McpSettingsTest.php diff --git a/app/Filament/Pages/Settings.php b/app/Filament/Pages/Settings.php index 34cd5aee..d413c59a 100644 --- a/app/Filament/Pages/Settings.php +++ b/app/Filament/Pages/Settings.php @@ -13,6 +13,7 @@ use Filament\Pages\SettingsPage; use App\Settings\GeneralSettings; use Illuminate\Support\Collection; +use Illuminate\Support\HtmlString; use Filament\Forms\Components\Select; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Grid; @@ -337,6 +338,16 @@ public function form(Schema $schema): Schema ] ), + Tab::make(trans('settings.mcp-title')) + ->schema( + [ + Toggle::make('enable_mcp') + ->label(trans('settings.mcp.enable-mcp')) + ->helperText(fn () => new HtmlString(trans('settings.mcp.enable-mcp-helper-text', ['url' => e(route('mcp.docs'))]))) + ->columnSpan(2), + ] + ), + Tab::make(trans('settings.notifications-title')) ->schema( [ diff --git a/app/Http/Controllers/McpController.php b/app/Http/Controllers/McpController.php new file mode 100644 index 00000000..fcb15169 --- /dev/null +++ b/app/Http/Controllers/McpController.php @@ -0,0 +1,26 @@ +<?php + +namespace App\Http\Controllers; + +use Laravel\Mcp\Server\Tool; +use App\Settings\GeneralSettings; +use App\Mcp\Servers\RoadmapServer; +use Illuminate\Contracts\View\View; + +class McpController extends Controller +{ + /** + * Explain how to connect to the MCP server, admins can read this before enabling it. + */ + public function __invoke(): View + { + $enabled = app(GeneralSettings::class)->enable_mcp; + + abort_unless($enabled || auth()->user()?->hasAdminAccess(), 404); + + return view('mcp', [ + 'enabled' => $enabled, + 'tools' => collect(RoadmapServer::TOOLS)->map(fn (string $tool): Tool => app($tool)), + ]); + } +} diff --git a/app/Http/Middleware/EnsureMcpIsEnabled.php b/app/Http/Middleware/EnsureMcpIsEnabled.php new file mode 100644 index 00000000..7417a236 --- /dev/null +++ b/app/Http/Middleware/EnsureMcpIsEnabled.php @@ -0,0 +1,23 @@ +<?php + +namespace App\Http\Middleware; + +use Closure; +use Illuminate\Http\Request; +use App\Settings\GeneralSettings; +use Symfony\Component\HttpFoundation\Response; + +class EnsureMcpIsEnabled +{ + /** + * Hide the MCP server when an admin has not enabled it in the settings. + * + * @param Closure(Request): (Response) $next + */ + public function handle(Request $request, Closure $next): Response + { + abort_unless(app(GeneralSettings::class)->enable_mcp, 404); + + return $next($request); + } +} diff --git a/app/Livewire/Profile.php b/app/Livewire/Profile.php index 00e70b9f..2f86f874 100644 --- a/app/Livewire/Profile.php +++ b/app/Livewire/Profile.php @@ -9,6 +9,7 @@ use Filament\Actions\Action; use Laravel\Fortify\Fortify; use Filament\Actions\BulkAction; +use App\Settings\GeneralSettings; use Filament\Support\Colors\Color; use App\SocialProviders\SsoProvider; use Illuminate\Support\Facades\Http; @@ -380,6 +381,7 @@ public function createMcpTokenAction(): Action { return Action::make('createMcpToken') ->label(trans('profile.mcp.create_token')) + ->visible(fn (): bool => app(GeneralSettings::class)->enable_mcp) ->color(Color::Blue) ->modalWidth('md') ->schema([ @@ -446,6 +448,7 @@ public function render() ? Fortify::currentEncrypter()->decrypt($this->user->two_factor_secret) : null, 'recoveryCodes' => $twoFactorConfirmed ? $this->user->recoveryCodes() : [], + 'mcpEnabled' => app(GeneralSettings::class)->enable_mcp, 'mcpTokens' => $this->user->tokens()->latest()->get(), ]); } diff --git a/app/Mcp/Servers/RoadmapServer.php b/app/Mcp/Servers/RoadmapServer.php index 08b8b75f..c6d60123 100644 --- a/app/Mcp/Servers/RoadmapServer.php +++ b/app/Mcp/Servers/RoadmapServer.php @@ -28,7 +28,12 @@ MARKDOWN)] class RoadmapServer extends Server { - protected array $tools = [ + /** + * The tools of this server, public so the MCP documentation page can list them. + * + * @var array<int, class-string<\Laravel\Mcp\Server\Tool>> + */ + public const TOOLS = [ ListProjectsTool::class, GetProjectTool::class, ListItemsTool::class, @@ -37,6 +42,8 @@ class RoadmapServer extends Server MoveItemTool::class, ]; + protected array $tools = self::TOOLS; + protected array $resources = [ // ]; diff --git a/app/Settings/GeneralSettings.php b/app/Settings/GeneralSettings.php index e20ce0a5..847bf8c5 100644 --- a/app/Settings/GeneralSettings.php +++ b/app/Settings/GeneralSettings.php @@ -37,6 +37,7 @@ class GeneralSettings extends Settings public bool $show_github_link; public bool $enable_leaderboard; public int $leaderboard_users_count; + public bool $enable_mcp; public function getInboxWorkflow(): InboxWorkflow { diff --git a/database/settings/2026_09_28_104542_add_mcp_settings.php b/database/settings/2026_09_28_104542_add_mcp_settings.php new file mode 100644 index 00000000..859d6704 --- /dev/null +++ b/database/settings/2026_09_28_104542_add_mcp_settings.php @@ -0,0 +1,10 @@ +<?php + +use Spatie\LaravelSettings\Migrations\SettingsMigration; + +return new class extends SettingsMigration { + public function up(): void + { + $this->migrator->add('general.enable_mcp', false); + } +}; diff --git a/lang/en/mcp.php b/lang/en/mcp.php new file mode 100644 index 00000000..5041786d --- /dev/null +++ b/lang/en/mcp.php @@ -0,0 +1,26 @@ +<?php + +return [ + 'title' => 'MCP server', + 'description' => 'Connect AI assistants such as Claude, ChatGPT or Cursor to :app through the Model Context Protocol (MCP). Your assistant can then look up projects and items, read the discussion and comment for you.', + 'disabled' => 'The MCP server is disabled, users can\'t see this page or connect. Enable it in the settings under "MCP".', + + 'permissions-title' => 'What your assistant can do', + 'permissions' => 'The assistant acts as you. It sees exactly the projects, items and comments you can see on the roadmap, and comments are posted under your name. Only admins and employees can move items between boards.', + + 'step-token-title' => '1. Create a token', + 'step-token' => 'Create a personal token in your profile. You can revoke it at any time, which disconnects every assistant that uses it.', + 'step-token-button' => 'Create a token', + 'step-token-login' => 'Log in to create a token', + + 'step-connect-title' => '2. Connect your assistant', + 'step-connect' => 'Add the server to your AI client and send the token as a bearer token. Replace <code>YOUR_TOKEN</code> with the token you created.', + 'endpoint' => 'Endpoint', + 'claude-code' => 'Claude Code', + 'json-config' => 'Cursor, VS Code and other clients that support remote MCP servers', + + 'step-use-title' => '3. Ask away', + 'step-use' => 'Ask your assistant things like "What is planned for the next release?", "Summarise the discussion on the dark mode request" or "Move the CSV export item to In progress".', + + 'tools-title' => 'Available tools', +]; diff --git a/lang/en/profile.php b/lang/en/profile.php index 57c3f246..61885cad 100644 --- a/lang/en/profile.php +++ b/lang/en/profile.php @@ -51,7 +51,8 @@ 'mcp' => [ 'heading' => 'MCP access', - 'description' => 'Connect AI assistants such as Claude or Cursor to the roadmap through MCP. Create a token and send it as a bearer token to the endpoint below. The assistant can do everything you can do on the roadmap.', + 'description' => 'Connect AI assistants such as Claude or Cursor to the roadmap through MCP. The assistant can do everything you can do on the roadmap.', + 'read_docs' => 'Read how to connect.', 'endpoint' => 'Endpoint', 'create_token' => 'Create token', 'token_name' => 'Token name', diff --git a/lang/en/settings.php b/lang/en/settings.php index 4a89abd6..082d5b01 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -88,6 +88,11 @@ 'show-likes' => 'Enable likes', 'show-likes-helper-text' => 'This will enable users to like the changelog items.', ], + 'mcp-title' => 'MCP', + 'mcp' => [ + 'enable-mcp' => 'Enable MCP server', + 'enable-mcp-helper-text' => 'Let users connect AI assistants such as Claude or Cursor to the roadmap with a personal token. They can read projects and items, and comment. Admins and employees can also move items. <a href=":url" target="_blank" class="underline">Read how it works</a>.', + ], 'notifications-title' => 'Notifications', 'notifications-helper-text' => 'This will send notifications once a new item has been created or when there is a new version of the roadmap software.', 'notifications' => [ diff --git a/resources/views/livewire/profile.blade.php b/resources/views/livewire/profile.blade.php index 4dc875d9..2a64f1e6 100644 --- a/resources/views/livewire/profile.blade.php +++ b/resources/views/livewire/profile.blade.php @@ -88,46 +88,51 @@ class="grid grid-cols-2 gap-2 rounded-lg bg-gray-50 p-4 font-mono text-sm dark:b </div> {{-- MCP access tokens --}} - <div class="space-y-4 border-t border-gray-200 dark:border-white/10 pt-6"> - <h2 class="text-lg tracking-tight font-bold">{{ trans('profile.mcp.heading') }}</h2> + @if($mcpEnabled) + <div class="space-y-4 border-t border-gray-200 dark:border-white/10 pt-6"> + <h2 class="text-lg tracking-tight font-bold">{{ trans('profile.mcp.heading') }}</h2> + + <p class="text-gray-500 text-sm max-w-2xl"> + {{ trans('profile.mcp.description') }} + <a href="{{ route('mcp.docs') }}" class="font-medium text-brand-600 hover:text-brand-500">{{ trans('profile.mcp.read_docs') }}</a> + </p> + + <div class="text-sm text-gray-500"> + {{ trans('profile.mcp.endpoint') }}: + <code class="select-all font-mono text-gray-700 dark:text-gray-300">{{ url('mcp') }}</code> + </div> - <p class="text-gray-500 text-sm max-w-2xl">{{ trans('profile.mcp.description') }}</p> + @if($newMcpToken) + <div class="space-y-2 rounded-lg bg-gray-50 p-4 dark:bg-gray-900 max-w-2xl"> + <p class="text-sm font-medium">{{ trans('profile.mcp.new_token') }}</p> + <code class="block break-all select-all font-mono text-sm text-gray-700 dark:text-gray-300">{{ $newMcpToken }}</code> + </div> + @endif - <div class="text-sm text-gray-500"> - {{ trans('profile.mcp.endpoint') }}: - <code class="select-all font-mono text-gray-700 dark:text-gray-300">{{ url('mcp') }}</code> - </div> + @if($mcpTokens->isEmpty()) + <p class="text-sm text-gray-500">{{ trans('profile.mcp.no_tokens') }}</p> + @else + <ul class="divide-y divide-gray-200 dark:divide-white/10 max-w-2xl"> + @foreach($mcpTokens as $token) + <li class="flex items-center justify-between gap-4 py-2" wire:key="mcp-token-{{ $token->id }}"> + <div class="min-w-0"> + <p class="text-sm font-medium truncate">{{ $token->name }}</p> + <p class="text-xs text-gray-500"> + {{ $token->last_used_at ? trans('profile.mcp.last_used', ['date' => $token->last_used_at->diffForHumans()]) : trans('profile.mcp.never_used') }} + </p> + </div> + + {{ ($this->revokeMcpTokenAction)(['token' => $token->id]) }} + </li> + @endforeach + </ul> + @endif - @if($newMcpToken) - <div class="space-y-2 rounded-lg bg-gray-50 p-4 dark:bg-gray-900 max-w-2xl"> - <p class="text-sm font-medium">{{ trans('profile.mcp.new_token') }}</p> - <code class="block break-all select-all font-mono text-sm text-gray-700 dark:text-gray-300">{{ $newMcpToken }}</code> + <div> + {{ $this->createMcpTokenAction }} </div> - @endif - - @if($mcpTokens->isEmpty()) - <p class="text-sm text-gray-500">{{ trans('profile.mcp.no_tokens') }}</p> - @else - <ul class="divide-y divide-gray-200 dark:divide-white/10 max-w-2xl"> - @foreach($mcpTokens as $token) - <li class="flex items-center justify-between gap-4 py-2" wire:key="mcp-token-{{ $token->id }}"> - <div class="min-w-0"> - <p class="text-sm font-medium truncate">{{ $token->name }}</p> - <p class="text-xs text-gray-500"> - {{ $token->last_used_at ? trans('profile.mcp.last_used', ['date' => $token->last_used_at->diffForHumans()]) : trans('profile.mcp.never_used') }} - </p> - </div> - - {{ ($this->revokeMcpTokenAction)(['token' => $token->id]) }} - </li> - @endforeach - </ul> - @endif - - <div> - {{ $this->createMcpTokenAction }} </div> - </div> + @endif @if($hasSsoLoginAvailable) <div> diff --git a/resources/views/mcp.blade.php b/resources/views/mcp.blade.php new file mode 100644 index 00000000..5b4af581 --- /dev/null +++ b/resources/views/mcp.blade.php @@ -0,0 +1,93 @@ +@section('title', trans('mcp.title')) +@section('description', trans('mcp.description', ['app' => config('app.name')])) + +@php + $endpoint = url('mcp'); + $claudeCodeCommand = 'claude mcp add --transport http ' . str(config('app.name'))->slug() . ' ' . $endpoint . ' --header "Authorization: Bearer YOUR_TOKEN"'; + $jsonConfig = json_encode([ + 'mcpServers' => [ + (string) str(config('app.name'))->slug() => [ + 'type' => 'http', + 'url' => $endpoint, + 'headers' => ['Authorization' => 'Bearer YOUR_TOKEN'], + ], + ], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +@endphp + +<x-app :breadcrumbs="[ + ['title' => trans('mcp.title'), 'url' => route('mcp.docs')] +]"> + <div class="max-w-3xl space-y-6"> + @unless($enabled) + <div class="rounded-xl bg-yellow-50 p-4 text-sm text-yellow-800 ring-1 ring-inset ring-yellow-600/20 dark:bg-yellow-400/10 dark:text-yellow-300 dark:ring-yellow-400/20"> + {{ trans('mcp.disabled') }} + </div> + @endunless + + <div class="space-y-2"> + <h1 class="text-2xl tracking-tight font-bold">{{ trans('mcp.title') }}</h1> + <p class="text-gray-500">{{ trans('mcp.description', ['app' => config('app.name')]) }}</p> + </div> + + <x-card class="p-4 space-y-2"> + <h2 class="text-lg tracking-tight font-bold">{{ trans('mcp.permissions-title') }}</h2> + <p class="text-sm text-gray-500">{{ trans('mcp.permissions') }}</p> + </x-card> + + <x-card class="p-4 space-y-3"> + <h2 class="text-lg tracking-tight font-bold">{{ trans('mcp.step-token-title') }}</h2> + <p class="text-sm text-gray-500">{{ trans('mcp.step-token') }}</p> + + <div> + @auth + <x-filament::button tag="a" :href="route('profile')" icon="heroicon-o-key"> + {{ trans('mcp.step-token-button') }} + </x-filament::button> + @else + <x-filament::button tag="a" :href="route('login')" icon="heroicon-o-arrow-right-on-rectangle"> + {{ trans('mcp.step-token-login') }} + </x-filament::button> + @endauth + </div> + </x-card> + + <x-card class="p-4 space-y-4"> + <h2 class="text-lg tracking-tight font-bold">{{ trans('mcp.step-connect-title') }}</h2> + <p class="text-sm text-gray-500">{!! trans('mcp.step-connect') !!}</p> + + <div class="space-y-1"> + <p class="text-sm font-medium">{{ trans('mcp.endpoint') }}</p> + <code class="block select-all break-all rounded-lg bg-gray-50 p-3 font-mono text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300">{{ $endpoint }}</code> + </div> + + <div class="space-y-1"> + <p class="text-sm font-medium">{{ trans('mcp.claude-code') }}</p> + <pre class="overflow-x-auto rounded-lg bg-gray-50 p-3 font-mono text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300"><code class="select-all">{{ $claudeCodeCommand }}</code></pre> + </div> + + <div class="space-y-1"> + <p class="text-sm font-medium">{{ trans('mcp.json-config') }}</p> + <pre class="overflow-x-auto rounded-lg bg-gray-50 p-3 font-mono text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300"><code class="select-all">{{ $jsonConfig }}</code></pre> + </div> + </x-card> + + <x-card class="p-4 space-y-2"> + <h2 class="text-lg tracking-tight font-bold">{{ trans('mcp.step-use-title') }}</h2> + <p class="text-sm text-gray-500">{{ trans('mcp.step-use') }}</p> + </x-card> + + <x-card class="p-4 space-y-3"> + <h2 class="text-lg tracking-tight font-bold">{{ trans('mcp.tools-title') }}</h2> + + <dl class="divide-y divide-gray-200 dark:divide-white/10"> + @foreach($tools as $tool) + <div class="py-3 first:pt-0 last:pb-0"> + <dt><code class="font-mono text-sm font-medium">{{ $tool->name() }}</code></dt> + <dd class="mt-1 text-sm text-gray-500">{{ $tool->description() }}</dd> + </div> + @endforeach + </dl> + </x-card> + </div> +</x-app> diff --git a/routes/ai.php b/routes/ai.php index da873451..85d755bc 100644 --- a/routes/ai.php +++ b/routes/ai.php @@ -2,5 +2,6 @@ use Laravel\Mcp\Facades\Mcp; use App\Mcp\Servers\RoadmapServer; +use App\Http\Middleware\EnsureMcpIsEnabled; -Mcp::web('/mcp', RoadmapServer::class)->middleware(['auth:sanctum', 'throttle:api']); +Mcp::web('/mcp', RoadmapServer::class)->middleware([EnsureMcpIsEnabled::class, 'auth:sanctum', 'throttle:api']); diff --git a/routes/web.php b/routes/web.php index 6538fd9d..1ebc7b83 100644 --- a/routes/web.php +++ b/routes/web.php @@ -54,6 +54,7 @@ Route::post('projects/{project}/items/{item}/update-board', [ItemController::class, 'updateBoard'])->middleware('authed')->name('projects.items.update-board'); Route::get('projects/{project}/boards/{board}', [BoardsController::class, 'show'])->name('projects.boards.show'); Route::get('activity', \App\Http\Controllers\ActivityController::class)->name('activity'); +Route::get('mcp/docs', \App\Http\Controllers\McpController::class)->name('mcp.docs'); Route::get('/email/verify', [VerificationController::class, 'show'])->middleware('auth')->name('verification.notice'); Route::post('/email/verification-notification', [VerificationController::class, 'resend'])->middleware(['auth', 'throttle:verification'])->name('verification.resend'); diff --git a/tests/Feature/Controllers/McpControllerTest.php b/tests/Feature/Controllers/McpControllerTest.php new file mode 100644 index 00000000..8060214c --- /dev/null +++ b/tests/Feature/Controllers/McpControllerTest.php @@ -0,0 +1,47 @@ +<?php + +use App\Enums\UserRole; +use function Pest\Laravel\get; +use App\Settings\GeneralSettings; + +it('explains how to connect to the mcp server', function () { + GeneralSettings::fake(['enable_mcp' => true]); + + get(route('mcp.docs')) + ->assertOk() + ->assertSee(url('mcp')) + ->assertSee(['list-projects', 'get-item', 'comment-on-item', 'move-item']) + ->assertSeeText(trans('mcp.step-token-login')) + ->assertDontSeeText(trans('mcp.disabled')); +}); + +it('links logged in users to their profile to create a token', function () { + GeneralSettings::fake(['enable_mcp' => true]); + + createAndLoginUser(); + + get(route('mcp.docs')) + ->assertOk() + ->assertSee(route('profile')) + ->assertSeeText(trans('mcp.step-token-button')); +}); + +it('is not available when mcp is disabled', function (?UserRole $role) { + GeneralSettings::fake(['enable_mcp' => false]); + + if ($role) { + createAndLoginUser(['role' => $role]); + } + + get(route('mcp.docs'))->assertNotFound(); +})->with([null, UserRole::User]); + +it('is available to admins and employees when mcp is disabled', function (UserRole $role) { + GeneralSettings::fake(['enable_mcp' => false]); + + createAndLoginUser(['role' => $role]); + + get(route('mcp.docs')) + ->assertOk() + ->assertSeeText(trans('mcp.disabled')); +})->with([UserRole::Admin, UserRole::Employee]); diff --git a/tests/Feature/Filament/McpSettingsTest.php b/tests/Feature/Filament/McpSettingsTest.php new file mode 100644 index 00000000..edee0aa1 --- /dev/null +++ b/tests/Feature/Filament/McpSettingsTest.php @@ -0,0 +1,21 @@ +<?php + +use Livewire\Livewire; +use App\Enums\UserRole; +use App\Filament\Pages\Settings; +use App\Settings\GeneralSettings; + +test('mcp is disabled by default', function () { + expect(app(GeneralSettings::class)->enable_mcp)->toBeFalse(); +}); + +test('admins can enable mcp in the settings', function () { + createAndLoginUser(['role' => UserRole::Admin]); + + Livewire::test(Settings::class) + ->fillForm(['enable_mcp' => true]) + ->call('save') + ->assertHasNoFormErrors(); + + expect(app(GeneralSettings::class)->refresh()->enable_mcp)->toBeTrue(); +}); diff --git a/tests/Feature/Livewire/ProfileMcpTokensTest.php b/tests/Feature/Livewire/ProfileMcpTokensTest.php index acb6f23c..a499f2cc 100644 --- a/tests/Feature/Livewire/ProfileMcpTokensTest.php +++ b/tests/Feature/Livewire/ProfileMcpTokensTest.php @@ -3,10 +3,13 @@ use App\Models\User; use Livewire\Livewire; use App\Livewire\Profile; +use App\Settings\GeneralSettings; use Laravel\Sanctum\PersonalAccessToken; use function Pest\Laravel\assertDatabaseHas; use function Pest\Laravel\assertDatabaseMissing; +beforeEach(fn () => GeneralSettings::fake(['enable_mcp' => true])); + it('creates an mcp token and shows it once', function () { $user = createAndLoginUser(); @@ -45,3 +48,13 @@ assertDatabaseHas(PersonalAccessToken::class, ['id' => $token->id]); }); + +it('hides mcp access when an admin has disabled mcp', function () { + GeneralSettings::fake(['enable_mcp' => false]); + + createAndLoginUser(); + + Livewire::test(Profile::class) + ->assertDontSee(trans('profile.mcp.heading')) + ->assertActionHidden('createMcpToken'); +}); diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php index 8222db65..8ddd70d1 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -2,9 +2,12 @@ use App\Models\User; use App\Enums\UserRole; +use App\Settings\GeneralSettings; use function Pest\Laravel\postJson; use function Pest\Laravel\withToken; +beforeEach(fn () => GeneralSettings::fake(['enable_mcp' => true])); + function listToolsRequest(): array { return ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => []]; @@ -39,3 +42,11 @@ function listToolsRequest(): array expect($tools)->toContain('move-item'); }); + +it('is not available when an admin has disabled mcp', function () { + GeneralSettings::fake(['enable_mcp' => false]); + + $token = User::factory()->admin()->create()->createToken('MCP')->plainTextToken; + + withToken($token)->postJson('/mcp', listToolsRequest())->assertNotFound(); +}); From c89af211ca2a833f55f33f619bf9fb707551eab4 Mon Sep 17 00:00:00 2001 From: Dennis <dennissmink@gmail.com> Date: Mon, 28 Sep 2026 12:54:34 +0200 Subject: [PATCH 3/3] Show MCP connection instructions per client in the docs and admin settings The docs page and the MCP settings tab now share the same instructions for Claude Code, Claude Desktop, ChatGPT, Cursor, VS Code and Codex. They show up in the settings once MCP is enabled. --- app/Filament/Pages/Settings.php | 9 +- app/Http/Controllers/McpController.php | 3 - lang/en/mcp.php | 45 +++++++- lang/en/settings.php | 6 +- resources/css/admin.css | 1 + resources/views/components/mcp/code.blade.php | 16 +++ .../components/mcp/instructions.blade.php | 101 ++++++++++++++++++ .../views/components/mcp/tools.blade.php | 12 +++ .../views/filament/settings/mcp.blade.php | 21 ++++ resources/views/mcp.blade.php | 40 +------ .../Feature/Controllers/McpControllerTest.php | 1 + tests/Feature/Filament/McpSettingsTest.php | 11 ++ 12 files changed, 220 insertions(+), 46 deletions(-) create mode 100644 resources/views/components/mcp/code.blade.php create mode 100644 resources/views/components/mcp/instructions.blade.php create mode 100644 resources/views/components/mcp/tools.blade.php create mode 100644 resources/views/filament/settings/mcp.blade.php diff --git a/app/Filament/Pages/Settings.php b/app/Filament/Pages/Settings.php index d413c59a..0be61bdc 100644 --- a/app/Filament/Pages/Settings.php +++ b/app/Filament/Pages/Settings.php @@ -13,11 +13,11 @@ use Filament\Pages\SettingsPage; use App\Settings\GeneralSettings; use Illuminate\Support\Collection; -use Illuminate\Support\HtmlString; use Filament\Forms\Components\Select; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Tabs; +use Filament\Schemas\Components\View; use Filament\Support\Enums\Alignment; use Filament\Schemas\Components\Group; use Filament\Forms\Components\Repeater; @@ -343,7 +343,12 @@ public function form(Schema $schema): Schema [ Toggle::make('enable_mcp') ->label(trans('settings.mcp.enable-mcp')) - ->helperText(fn () => new HtmlString(trans('settings.mcp.enable-mcp-helper-text', ['url' => e(route('mcp.docs'))]))) + ->helperText(trans('settings.mcp.enable-mcp-helper-text')) + ->live() + ->columnSpan(2), + + View::make('filament.settings.mcp') + ->visible(fn (Get $get): bool => (bool) $get('enable_mcp')) ->columnSpan(2), ] ), diff --git a/app/Http/Controllers/McpController.php b/app/Http/Controllers/McpController.php index fcb15169..ac324377 100644 --- a/app/Http/Controllers/McpController.php +++ b/app/Http/Controllers/McpController.php @@ -2,9 +2,7 @@ namespace App\Http\Controllers; -use Laravel\Mcp\Server\Tool; use App\Settings\GeneralSettings; -use App\Mcp\Servers\RoadmapServer; use Illuminate\Contracts\View\View; class McpController extends Controller @@ -20,7 +18,6 @@ public function __invoke(): View return view('mcp', [ 'enabled' => $enabled, - 'tools' => collect(RoadmapServer::TOOLS)->map(fn (string $tool): Tool => app($tool)), ]); } } diff --git a/lang/en/mcp.php b/lang/en/mcp.php index 5041786d..91fdf4f3 100644 --- a/lang/en/mcp.php +++ b/lang/en/mcp.php @@ -14,10 +14,49 @@ 'step-token-login' => 'Log in to create a token', 'step-connect-title' => '2. Connect your assistant', - 'step-connect' => 'Add the server to your AI client and send the token as a bearer token. Replace <code>YOUR_TOKEN</code> with the token you created.', + 'step-connect' => 'Pick your AI client and follow the steps. The token is sent as a bearer token with every request.', 'endpoint' => 'Endpoint', - 'claude-code' => 'Claude Code', - 'json-config' => 'Cursor, VS Code and other clients that support remote MCP servers', + 'copy' => 'Copy', + 'copied' => 'Copied!', + 'run-in-terminal' => 'Run in your terminal', + + 'clients' => [ + 'claude-code' => [ + 'Run the command below in your terminal, with <code>YOUR_TOKEN</code> replaced by your token.', + 'Start Claude Code and run <code>/mcp</code> to check that the server is connected.', + ], + 'claude-desktop' => [ + 'Make sure <a href="https://nodejs.org" target="_blank" class="underline">Node.js</a> is installed, Claude Desktop uses it to connect to the server.', + 'Open Claude Desktop and go to <strong>Settings → Developer → Edit config</strong>.', + 'Add the server below to <code>claude_desktop_config.json</code>, with <code>YOUR_TOKEN</code> replaced by your token.', + 'Restart Claude Desktop, the roadmap tools show up in the tools menu of the chat.', + ], + 'claude-web' => 'The web version of Claude (claude.ai) only supports connectors that sign in with OAuth, which the roadmap does not offer yet. Use Claude Desktop or Claude Code instead.', + 'chatgpt' => [ + 'Open ChatGPT and go to <strong>Settings → Apps & Connectors → Advanced settings</strong>, and turn on <strong>Developer mode</strong>.', + 'Go back to <strong>Apps & Connectors</strong> and click <strong>Create</strong>.', + 'Give the connector a name, and use the URL below as the MCP server URL.', + 'Choose <strong>Access token / API key</strong> as authentication and paste your token.', + 'Enable the connector in a new chat through the <strong>+</strong> menu.', + ], + 'chatgpt-url' => 'MCP server URL', + 'chatgpt-note' => 'Developer mode is only available on some ChatGPT plans, and ChatGPT must be able to reach the roadmap over the internet.', + 'cursor' => [ + 'Open <code>~/.cursor/mcp.json</code>, or <strong>Cursor Settings → MCP → Add new MCP server</strong>.', + 'Add the server below, with <code>YOUR_TOKEN</code> replaced by your token.', + 'The roadmap tools become available in the Cursor agent.', + ], + 'vscode' => [ + 'Create <code>.vscode/mcp.json</code> in your project, or run <strong>MCP: Open User Configuration</strong> from the command palette to add it for all projects.', + 'Add the server below, with <code>YOUR_TOKEN</code> replaced by your token.', + 'Open Copilot Chat in agent mode, the roadmap tools show up in the tools picker.', + ], + 'codex' => [ + 'Add the server below to <code>~/.codex/config.toml</code>.', + 'Store your token in the <code>ROADMAP_TOKEN</code> environment variable, for example in your shell profile.', + 'Start Codex and run <code>/mcp</code> to check that the server is connected.', + ], + ], 'step-use-title' => '3. Ask away', 'step-use' => 'Ask your assistant things like "What is planned for the next release?", "Summarise the discussion on the dark mode request" or "Move the CSV export item to In progress".', diff --git a/lang/en/settings.php b/lang/en/settings.php index 082d5b01..98324818 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -91,7 +91,11 @@ 'mcp-title' => 'MCP', 'mcp' => [ 'enable-mcp' => 'Enable MCP server', - 'enable-mcp-helper-text' => 'Let users connect AI assistants such as Claude or Cursor to the roadmap with a personal token. They can read projects and items, and comment. Admins and employees can also move items. <a href=":url" target="_blank" class="underline">Read how it works</a>.', + 'enable-mcp-helper-text' => 'Let users connect AI assistants such as Claude, ChatGPT or Cursor to the roadmap with a personal token.', + 'how-it-works' => 'How it works', + 'how-it-works-description' => 'Users create a personal token under "MCP access" on their profile page and add the roadmap to their AI client. Share the documentation page with your users, it has the same instructions as below.', + 'view-docs' => 'Open the documentation page', + 'connect-heading' => 'Connect an AI client', ], 'notifications-title' => 'Notifications', 'notifications-helper-text' => 'This will send notifications once a new item has been created or when there is a new version of the roadmap software.', diff --git a/resources/css/admin.css b/resources/css/admin.css index 0f4c87fb..3cbb4d79 100644 --- a/resources/css/admin.css +++ b/resources/css/admin.css @@ -2,3 +2,4 @@ @source '../../app/Filament'; @source '../../resources/views/filament'; +@source '../../resources/views/components/mcp'; diff --git a/resources/views/components/mcp/code.blade.php b/resources/views/components/mcp/code.blade.php new file mode 100644 index 00000000..8db1fe59 --- /dev/null +++ b/resources/views/components/mcp/code.blade.php @@ -0,0 +1,16 @@ +@props(['code', 'label' => null]) + +<div class="space-y-1" x-data="{ copied: false }"> + <div class="flex items-end justify-between gap-2"> + <p class="text-sm font-medium text-gray-950 dark:text-white">{{ $label }}</p> + + <button type="button" + class="rounded-md px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-gray-950/10 hover:bg-gray-50 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/5" + x-on:click="navigator.clipboard.writeText($refs.code.innerText); copied = true; setTimeout(() => copied = false, 2000)"> + <span x-show="! copied">{{ trans('mcp.copy') }}</span> + <span x-show="copied" x-cloak>{{ trans('mcp.copied') }}</span> + </button> + </div> + + <pre class="overflow-x-auto rounded-lg bg-gray-50 p-3 font-mono text-sm text-gray-700 ring-1 ring-gray-950/5 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10"><code x-ref="code">{{ $code }}</code></pre> +</div> diff --git a/resources/views/components/mcp/instructions.blade.php b/resources/views/components/mcp/instructions.blade.php new file mode 100644 index 00000000..dd013687 --- /dev/null +++ b/resources/views/components/mcp/instructions.blade.php @@ -0,0 +1,101 @@ +@php + $endpoint = url('mcp'); + $serverName = Illuminate\Support\Str::slug(config('app.name')) ?: 'roadmap'; + $json = fn (array $config): string => json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + $clients = [ + 'claude-code' => [ + 'label' => 'Claude Code', + 'steps' => trans('mcp.clients.claude-code'), + 'snippets' => [ + ['label' => trans('mcp.run-in-terminal'), 'code' => "claude mcp add --transport http {$serverName} {$endpoint} --header \"Authorization: Bearer YOUR_TOKEN\""], + ], + ], + 'claude-desktop' => [ + 'label' => 'Claude Desktop', + 'steps' => trans('mcp.clients.claude-desktop'), + 'snippets' => [ + ['label' => 'claude_desktop_config.json', 'code' => $json(['mcpServers' => [$serverName => [ + 'command' => 'npx', + 'args' => ['-y', 'mcp-remote', $endpoint, '--header', 'Authorization:${AUTH_HEADER}'], + 'env' => ['AUTH_HEADER' => 'Bearer YOUR_TOKEN'], + ]]])], + ], + 'note' => trans('mcp.clients.claude-web'), + ], + 'chatgpt' => [ + 'label' => 'ChatGPT', + 'steps' => trans('mcp.clients.chatgpt'), + 'snippets' => [ + ['label' => trans('mcp.clients.chatgpt-url'), 'code' => $endpoint], + ], + 'note' => trans('mcp.clients.chatgpt-note'), + ], + 'cursor' => [ + 'label' => 'Cursor', + 'steps' => trans('mcp.clients.cursor'), + 'snippets' => [ + ['label' => '~/.cursor/mcp.json', 'code' => $json(['mcpServers' => [$serverName => [ + 'url' => $endpoint, + 'headers' => ['Authorization' => 'Bearer YOUR_TOKEN'], + ]]])], + ], + ], + 'vscode' => [ + 'label' => 'VS Code', + 'steps' => trans('mcp.clients.vscode'), + 'snippets' => [ + ['label' => '.vscode/mcp.json', 'code' => $json(['servers' => [$serverName => [ + 'type' => 'http', + 'url' => $endpoint, + 'headers' => ['Authorization' => 'Bearer YOUR_TOKEN'], + ]]])], + ], + ], + 'codex' => [ + 'label' => 'Codex', + 'steps' => trans('mcp.clients.codex'), + 'snippets' => [ + ['label' => '~/.codex/config.toml', 'code' => "[mcp_servers.{$serverName}]\nurl = \"{$endpoint}\"\nbearer_token_env_var = \"ROADMAP_TOKEN\""], + ['label' => trans('mcp.run-in-terminal'), 'code' => 'export ROADMAP_TOKEN="YOUR_TOKEN"'], + ], + ], + ]; +@endphp + +<div {{ $attributes->merge(['class' => 'space-y-4']) }} x-data="{ client: 'claude-code' }"> + <x-mcp.code :label="trans('mcp.endpoint')" :code="$endpoint" /> + + <div class="flex flex-wrap gap-2" role="tablist"> + @foreach($clients as $key => $client) + <button type="button" + role="tab" + class="rounded-lg px-3 py-1.5 text-sm font-medium ring-1 transition" + x-bind:aria-selected="client === @js($key)" + x-bind:class="client === @js($key) + ? 'bg-gray-900 text-white ring-gray-900 dark:bg-white dark:text-gray-900 dark:ring-white' + : 'bg-white text-gray-700 ring-gray-950/10 hover:bg-gray-50 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10'" + x-on:click="client = @js($key)"> + {{ $client['label'] }} + </button> + @endforeach + </div> + + @foreach($clients as $key => $client) + <div class="space-y-3" x-show="client === @js($key)" @if(! $loop->first) x-cloak @endif> + <ol class="list-decimal space-y-1 pl-5 text-sm text-gray-600 dark:text-gray-400"> + @foreach($client['steps'] as $step) + <li>{!! $step !!}</li> + @endforeach + </ol> + + @foreach($client['snippets'] as $snippet) + <x-mcp.code :label="$snippet['label']" :code="$snippet['code']" /> + @endforeach + + @isset($client['note']) + <p class="text-sm text-gray-500 dark:text-gray-400">{{ $client['note'] }}</p> + @endisset + </div> + @endforeach +</div> diff --git a/resources/views/components/mcp/tools.blade.php b/resources/views/components/mcp/tools.blade.php new file mode 100644 index 00000000..f785befb --- /dev/null +++ b/resources/views/components/mcp/tools.blade.php @@ -0,0 +1,12 @@ +@php + $tools = collect(App\Mcp\Servers\RoadmapServer::TOOLS)->map(fn (string $tool) => app($tool)); +@endphp + +<dl {{ $attributes->merge(['class' => 'divide-y divide-gray-200 dark:divide-white/10']) }}> + @foreach($tools as $tool) + <div class="py-3 first:pt-0 last:pb-0"> + <dt><code class="font-mono text-sm font-medium text-gray-950 dark:text-white">{{ $tool->name() }}</code></dt> + <dd class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $tool->description() }}</dd> + </div> + @endforeach +</dl> diff --git a/resources/views/filament/settings/mcp.blade.php b/resources/views/filament/settings/mcp.blade.php new file mode 100644 index 00000000..a339f6b2 --- /dev/null +++ b/resources/views/filament/settings/mcp.blade.php @@ -0,0 +1,21 @@ +<div class="space-y-6"> + <x-filament::section :heading="trans('settings.mcp.how-it-works')"> + <div class="space-y-3 text-sm text-gray-600 dark:text-gray-400"> + <p>{{ trans('settings.mcp.how-it-works-description') }}</p> + <p>{{ trans('mcp.permissions') }}</p> + <p> + <x-filament::link :href="route('mcp.docs')" target="_blank" icon="heroicon-m-arrow-top-right-on-square" icon-position="after"> + {{ trans('settings.mcp.view-docs') }} + </x-filament::link> + </p> + </div> + </x-filament::section> + + <x-filament::section :heading="trans('settings.mcp.connect-heading')" :description="trans('mcp.step-connect')"> + <x-mcp.instructions /> + </x-filament::section> + + <x-filament::section :heading="trans('mcp.tools-title')" collapsible collapsed> + <x-mcp.tools /> + </x-filament::section> +</div> diff --git a/resources/views/mcp.blade.php b/resources/views/mcp.blade.php index 5b4af581..b5f2a78d 100644 --- a/resources/views/mcp.blade.php +++ b/resources/views/mcp.blade.php @@ -1,20 +1,6 @@ @section('title', trans('mcp.title')) @section('description', trans('mcp.description', ['app' => config('app.name')])) -@php - $endpoint = url('mcp'); - $claudeCodeCommand = 'claude mcp add --transport http ' . str(config('app.name'))->slug() . ' ' . $endpoint . ' --header "Authorization: Bearer YOUR_TOKEN"'; - $jsonConfig = json_encode([ - 'mcpServers' => [ - (string) str(config('app.name'))->slug() => [ - 'type' => 'http', - 'url' => $endpoint, - 'headers' => ['Authorization' => 'Bearer YOUR_TOKEN'], - ], - ], - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); -@endphp - <x-app :breadcrumbs="[ ['title' => trans('mcp.title'), 'url' => route('mcp.docs')] ]"> @@ -54,22 +40,9 @@ <x-card class="p-4 space-y-4"> <h2 class="text-lg tracking-tight font-bold">{{ trans('mcp.step-connect-title') }}</h2> - <p class="text-sm text-gray-500">{!! trans('mcp.step-connect') !!}</p> - - <div class="space-y-1"> - <p class="text-sm font-medium">{{ trans('mcp.endpoint') }}</p> - <code class="block select-all break-all rounded-lg bg-gray-50 p-3 font-mono text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300">{{ $endpoint }}</code> - </div> + <p class="text-sm text-gray-500">{{ trans('mcp.step-connect') }}</p> - <div class="space-y-1"> - <p class="text-sm font-medium">{{ trans('mcp.claude-code') }}</p> - <pre class="overflow-x-auto rounded-lg bg-gray-50 p-3 font-mono text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300"><code class="select-all">{{ $claudeCodeCommand }}</code></pre> - </div> - - <div class="space-y-1"> - <p class="text-sm font-medium">{{ trans('mcp.json-config') }}</p> - <pre class="overflow-x-auto rounded-lg bg-gray-50 p-3 font-mono text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300"><code class="select-all">{{ $jsonConfig }}</code></pre> - </div> + <x-mcp.instructions /> </x-card> <x-card class="p-4 space-y-2"> @@ -80,14 +53,7 @@ <x-card class="p-4 space-y-3"> <h2 class="text-lg tracking-tight font-bold">{{ trans('mcp.tools-title') }}</h2> - <dl class="divide-y divide-gray-200 dark:divide-white/10"> - @foreach($tools as $tool) - <div class="py-3 first:pt-0 last:pb-0"> - <dt><code class="font-mono text-sm font-medium">{{ $tool->name() }}</code></dt> - <dd class="mt-1 text-sm text-gray-500">{{ $tool->description() }}</dd> - </div> - @endforeach - </dl> + <x-mcp.tools /> </x-card> </div> </x-app> diff --git a/tests/Feature/Controllers/McpControllerTest.php b/tests/Feature/Controllers/McpControllerTest.php index 8060214c..f23fdce6 100644 --- a/tests/Feature/Controllers/McpControllerTest.php +++ b/tests/Feature/Controllers/McpControllerTest.php @@ -11,6 +11,7 @@ ->assertOk() ->assertSee(url('mcp')) ->assertSee(['list-projects', 'get-item', 'comment-on-item', 'move-item']) + ->assertSeeText(['Claude Code', 'Claude Desktop', 'ChatGPT', 'Cursor', 'VS Code', 'Codex']) ->assertSeeText(trans('mcp.step-token-login')) ->assertDontSeeText(trans('mcp.disabled')); }); diff --git a/tests/Feature/Filament/McpSettingsTest.php b/tests/Feature/Filament/McpSettingsTest.php index edee0aa1..7bad57f8 100644 --- a/tests/Feature/Filament/McpSettingsTest.php +++ b/tests/Feature/Filament/McpSettingsTest.php @@ -19,3 +19,14 @@ expect(app(GeneralSettings::class)->refresh()->enable_mcp)->toBeTrue(); }); + +test('the settings show how to connect once mcp is enabled', function () { + createAndLoginUser(['role' => UserRole::Admin]); + + Livewire::test(Settings::class) + ->fillForm(['enable_mcp' => false]) + ->assertDontSeeText(trans('settings.mcp.connect-heading')) + ->fillForm(['enable_mcp' => true]) + ->assertSeeText([trans('settings.mcp.connect-heading'), 'Claude Desktop', 'ChatGPT']) + ->assertSee(route('mcp.docs')); +});