Northwind Tickets
mcp-lab
Eine Laravel-App, die dazu dient, jeden Teil von Laravel MCP zu erzwingen. Fake-Helpdesk. Fake-Firma. Wirf sie weg, wenn du fertig bist.
Dies ist die Standardreferenz dafür, wie MCP in Laravel funktioniert und wie dieses Repo es verwendet.
Dies ist ein Fitnessstudio, kein Produkt. Wenn du dich dabei ertappst, eine Schriftart auszuwählen oder eine Abrechnungsseite zu erfinden, hör auf.
Wie Laravel MCP funktioniert
Model Context Protocol ist JSON-RPC. Ein KI-Host listet auf, was dein Server bereitstellt, und ruft es dann auf. Cursor und Inspector sind die Hosts, um die es in diesem Repo geht. Laravel MCP, laravel/mcp 0.9.x hier, ist der Wrapper.
Du erfindest kein Protokoll. Du schreibst PHP-Klassen und registrierst sie.
Server
Ein Server ist eine Klasse, die Laravel\Mcp\Server erweitert. Es ist ein Katalog von Tools, Ressourcen und Prompts.
#[Name('Northwind Tickets')]
#[Version('0.0.1')]
#[Instructions('...')]
class TicketsServer extends Server
{
protected array $tools = [/* ... */];
protected array $resources = [/* ... */];
protected array $prompts = [/* ... */];
}#[Name], #[Version], #[Instructions] und #[Icon] sind Metadaten, die der Host dem Modell zeigt. Instructions sind der System-Prompt für diesen Server. Halte sie kurz und operativ.
Erstelle einen mit php artisan make:mcp-server. Registriere ihn in routes/ai.php. Laravel lädt diese Datei selbst. Füge sie nicht zu bootstrap/app.php hinzu.
Lokal vs. Web
Dieselbe Serverklasse. Zwei Wege hinein.
Mcp::local('tickets', TicketsServer::class);
Mcp::web('/mcp/tickets', TicketsServer::class)->middleware(['auth:api', 'throttle:mcp']);Lokal ist stdio. Der Host führt php artisan mcp:start tickets als untergeordneten Prozess aus. Das ist die tägliche Cursor-Schleife. Keine HTTP-Sitzung, keine Cookies, kein Passport.
Web ist HTTP JSON-RPC unter diesem Pfad. Inspector und Remote-Hosts verwenden es. Middleware wird angewendet, und hier lebt OAuth.
Wickle routes/ai.php nicht in die web-Middleware-Gruppe ein. CSRF blockiert Inspector.
Tools, Ressourcen und Prompts
Ein Server kann Tools, Ressourcen und Prompts bereitstellen. Generiere sie mit make:mcp-tool, make:mcp-resource und make:mcp-prompt. Füge dann die Klasse zu den Server-Arrays hinzu. Eine nicht registrierte Klasse tut nichts.
Tools sind Aktionen. Das Modell ruft sie mit Argumenten auf. schema() ist das JSON-Schema, das der Host bewirbt. handle(Request $request) erledigt die Arbeit. $request->validate() ist gewöhnliche Laravel-Validierung. Schreibe Nachrichten, auf die ein Modell reagieren kann, wie Gib mir die Ticket-ID, z. B. 12., nicht Das Feld ticket_id ist erforderlich.
Ressourcen sind lesbare Dokumente unter einer URI. Eine statische URI verwendet #[Uri('desk://playbook')]. Vorlagen implementieren HasUriTemplate und lesen Variablen mit $request->get('id'). Der MIME-Typ verwendet #[MimeType]. Der Host kann sie auflisten und lesen, ohne ein Tool aufzurufen.
Prompts sind wiederverwendbare Nachrichtenvorlagen. arguments() deklariert, was der Host sammeln soll. handle() gibt Nachrichten zurück, normalerweise eine Assistentenanweisung plus eine Benutzernachricht, die echte Daten enthält. Das Modell schreibt dann daraus.
Konstruktor-Injektion von Repositories. Führe keine Abfragen in der Tool-Klasse durch. handle() kann auch Laravel-Dienste per Typ-Hint einbinden.
Antworten
handle() gibt eine Laravel\Mcp\Response, eine Antwort-Factory, ein Array von Antworten oder einen Generator zurück.
Form | Wie |
Text |
|
Fehler |
|
Strukturiertes JSON |
|
Mehrere Textblöcke |
|
Ressourcenlink |
|
Blob von der Festplatte |
|
HTML-App |
|
Fortschritt |
|
Response::structured darf nicht leer sein und gibt eine Factory zurück. Um strukturiertes JSON mit Ressourcenlinks zu mischen, baue beide und füge die Nutzlast mit withStructuredContent hinzu. Das macht list_tickets.
Die Klasse $meta ist Metadaten auf dem Tool selbst. ->withMeta([...]) ist Metadaten auf einer einzelnen Antwort. create_ticket hat das erste. get_ticket hat das zweite.
Annotationen
Hinweise für den Host. Sie erzwingen nichts in PHP. Policies tun das immer noch.
Attribut | Bedeutung hier |
| Schreibt nicht |
| Sicher zu wiederholen |
| Löscht oder zerstört Zustand |
| Kann die Außenwelt berühren. |
| Ressourcen-Hinweise |
| Dieses Tool öffnet eine MCP-App |
shouldRegister(Request $request): bool verbirgt ein Tool, eine Ressource oder einen Prompt aus der Liste. Wenn der Host dennoch versucht, ein verstecktes aufzurufen, gibt der Server not-found zurück. Verwende es für Rollen-Gates. delete_ticket ist nur für Admins. Überprüfe trotzdem $request->user()->can(...) innerhalb von handle(). Auflisten und Ausführen sind verschiedene Türen.
Autorisierung
$request->user() ist der angemeldete Benutzer, wie in einem Controller. Rufe $user->can('update', $ticket) auf und gib Response::error('Permission denied.') zurück. Erfinde kein zweites Auth-System.
Lokale Server haben keine HTTP-Sitzung. Der Ticket-Server dieser App meldet sich in boot() als Sam an, wenn Auth leer ist. Webserver erhalten den Benutzer von Passport.
Der MCP-Client
Laravel kann auch einen MCP-Server aufrufen. Benannte Clients leben in einem Service-Provider.
Mcp::registerClient('directory', fn () => Client::local('php', [
'artisan', 'mcp:start', 'directory',
]));Dann macht Ticket-Code Mcp::client('directory')->callTool('get_person', ['id' => $id]) oder ->readResource('directory://people/'.$id).
Client::local startet einen Prozess. Client::web($url) ist HTTP. Gleiche-App-HTTP gegen php artisan serve verklemmt, weil dieser Prozess single-threaded ist. Verwende local für Aufrufe innerhalb derselben App.
MCP-Apps
Eine AppResource gibt ein eigenständiges HTML-Dokument unter einer ui://-URI zurück. Ein Tool, das mit #[RendersApp(resource: QueueApp::class)] markiert ist, teilt einem fähigen Host mit, dieses HTML abzurufen und in einen sandboxed iframe zu legen.
Die Blade-Ansicht verwendet <x-mcp::app>. Diese Komponente liefert das Client-SDK. Im iframe gibt dir createMcpApp app.callServerTool(...). Vite und React gelten nicht. Tailwind und Alpine kommen von #[AppMeta(libraries: [Library::Tailwind, Library::Alpine])].
Visibility::App verbirgt ein Tool vor dem Modell, sodass nur der iframe es aufrufen kann. get_queue_data ist dieses Tool.
Cursor listet diese Tools auf. Es rendert den iframe nicht. Pest ist, wie du weißt, dass die Klassen funktionieren.
Auth im Web
Die Laravel-Dokumentation bietet Sanctum und Passport. Sanctum ist ein Bearer-Token. Passport ist OAuth 2.1, was das Protokoll spezifiziert.
Diese App verwendet Passport. Mcp::oauthRoutes() registriert Discovery und dynamische Client-Registrierung. Web-Routen verwenden auth:api. Laravel MCP bewirbt einen einzelnen mcp:use-Scope. Veröffentliche mcp-views und weise Passport::authorizationView auf resources/views/mcp/authorize.blade.php zu. Lass diese Blade-Datei in Ruhe.
Testen
Inspector ist zum Herumstochern. Pest ist, wie du weißt, dass Policies halten.
TicketsServer::actingAs($sam)
->tool(ListTicketsTool::class, ['status' => 'open'])
->assertOk()
->assertSee('...');
TicketsServer::resource(TicketResource::class, ['id' => $ticket->id]);
TicketsServer::prompt(DraftReplyPrompt::class, ['ticket_id' => $ticket->id, 'tone' => 'curt']);Vorlagenressourcen nehmen die URI-Variablen als zweites Argument. Der Helfer erweitert desk://tickets/{id}.
assertSee liest nur Text und strukturierte Daten. Ressourcenlinks und _meta liegen auf der rohen JSON-RPC-Nutzlast. Die mcpRpc() und mcpToolContent() dieses Repos in tests/Helpers.php lesen das. Generator-Tools verwenden assertSentNotification und assertNotificationCount. Das Endergebnis enthält immer noch die Text-Nutzlasten.
Web-Auth ist ein HTTP-Test. POST /mcp/tickets mit mcpTicketsCall(). Nicht authentifizierte Anfragen müssen 401 sein, keine Login-Weiterleitung.
Was dieses Repo ist
Northwind Support. Eine Laravel-13-App, PHP 8.4, SQLite. Inertia und React sind nur die Dump-Seite. Der Agent ist der Schreibpfad.
Zwei MCP-Server, jeweils zweimal registriert, lokal und web.
routes/ai.php
Mcp::local('directory', DirectoryServer::class);
Mcp::local('tickets', TicketsServer::class);
Mcp::oauthRoutes();
Mcp::web('/mcp/directory', DirectoryServer::class)
->middleware(['auth:api', 'throttle:mcp']);
Mcp::web('/mcp/tickets', TicketsServer::class)
->middleware(['auth:api', 'throttle:mcp']);DirectoryServer ist schreibgeschützt für Personen und Teams. TicketsServer ist das Helpdesk. Ticket-Tools dürfen User oder Team nicht über Eloquent abfragen. Sie schlagen Personen über den benannten directory-Client nach, get_person und directory://people/{id}. Deshalb gibt es zwei Server. Wenn du User::find() aus einem Ticket-Tool machst, hast du den Punkt übersprungen.
Abfragen leben in DirectoryRepository und TicketRepository. Tools injizieren diese.
Domäne
Drei Rollen auf users.role.
Rolle | Was sie können |
| Tickets öffnen, eigene kommentieren, öffentliche Wissensdatenbank lesen |
| Jedes Ticket sehen, zuweisen, kommentieren, Status ändern, interne Wissensdatenbank lesen |
| Alles, was ein Agent kann, plus Tickets löschen und den Wochenrückblick-Prompt |
Policies sind gewöhnliche Laravel-Policies. TicketPolicy deckt view, update, comment und delete ab. ArticlePolicy verbirgt interne Artikel vor Requesters. Personal ist Role::isStaff(), Agent oder Admin.
Login-Benutzer kommen aus LabSeeder. Passwort für alle ist password.
Rolle | |
| requester |
| agent |
| admin |
email_verified_at ist gesetzt. Fortify hat Verifizierung aktiviert. User implementiert MustVerifyEmail nicht, also wirst du nicht blockiert.
Jonah Hale, jonah@northwind.test, ist der Bereitschafts-Agent.
Tabellen
Halte sie klein. Wenn eine Spalte nicht von einem Tool benötigt wird, ist sie nicht da.
users. Starter-Kit-Spalten plus role (requester\|agent\|admin), team_id nullable, title, on_call.
teams. name, slug. Support, Billing, Warehouse.
tickets. subject, body, status (open\|pending\|closed), priority (low\|normal\|high\|urgent), requester_id, assignee_id nullable, team_id nullable.
comments. ticket_id, user_id, body.
articles. slug, title, body, visibility (public\|internal). Vier Zeilen. Öffentliche sind Rückerstattungen und Versand. Interne sind Eskalation und Rückerstattungsmissbrauch.
LabSeeder läuft von DatabaseSeeder. Es ist idempotent genug, um nach migrate:fresh erneut ausgeführt zu werden. Zwanzig Tickets, dreißig Kommentare, acht Personen, ein PNG unter storage/app/badge.png.
DirectoryServer
Schreibgeschützt. Die Anweisungen sagen das. Name Northwind Directory, Version 0.0.1, türkisfarbenes Symbol.
Tools
Tool | Was es tut |
| Suche nach Name, E-Mail oder Titel. Optionaler Team-Slug. Strukturierte |
| Einzelne Benutzer-ID nachschlagen. Benutzerdefinierte Validierungsmeldungen. Strukturiertes |
| Keine Pflichtargumente. Zwei Textblöcke, zuerst Namen, dann Slugs |
| Gibt den |
Ressourcen
URI | Was es tut |
| Statisches Markdown. |
| Personendossier. |
| Team-Dossier. Zweites Template, damit das erste kein Einzelfall ist |
| Wer Bereitschaft hat. |
| Kleines PNG über |
Hier gibt es keine Prompts. Sie gehören auf den Ticket-Server, wo sie etwas zu sagen haben.
TicketsServer
Name Northwind Tickets, Version 0.0.1, dunkles Icon.
boot() meldet sich als sam@northwind.test an, wenn niemand authentifiziert ist. Lokales Cursor hat keine HTTP-Session. Ohne dies würde jedes Tool You must be signed in. sagen. Web-Anfragen haben bereits einen Passport-Benutzer, daher kehrt boot() früh zurück.
Tools
Tool | Was es tut |
| Listet Tickets, die der Benutzer sehen kann. Status- und Prioritätsfilter. Strukturierte Ausgabe plus |
| Ein Ticket. Namen von Bearbeiter und Anfragendem kommen von |
| Öffnet ein Ticket. Die Klasse |
| Fügt nach der |
|
|
| Setzt |
|
|
| Schließt jedes |
| Durchsucht Artikel, die der aktuelle Benutzer sehen kann. Strukturierte |
| Modell-sichtbar. |
| Gleiche App, |
list_tickets kann TicketResource::uri() nicht für Links verwenden. Diese Methode gibt das Template desk://tickets/{id} zurück. Links müssen die erweiterte Zeichenkette sein.
assign_ticket und get_ticket weigern sich, User anzufassen. Wenn der Directory-Client abgekoppelt ist, schlägt die Zuweisung fehl. Pest beweist das.
Prompts
Prompt | Was es tut |
| Nimmt |
| Nimmt |
| Zusammenfassung offener Tickets. |
Ressourcen
URI | Was es tut |
| Statisches Markdown, hohe Priorität. Wie man triagiert |
| Markdown-Liste offener Tickets, die der aktuelle Benutzer sehen kann |
| Markdown-Dossier mit Kommentaren |
| Ein Artikel. Fehlende und verbotene Slugs geben denselben Fehler zurück |
| Nur für Mitarbeiter: Liste des internen Eskalationsartikels |
| Interaktives Queue-iframe |
desk://queue ist Markdown. QueueApp ist das iframe. Verwechsle sie nicht.
QueueApp
QueueApp erweitert AppResource. Blade unter resources/views/mcp/queue-app.blade.php. Alpine innerhalb von <x-mcp::app>. Aktualisierung ruft get_queue_data über app.callServerTool auf.
Dies ist keine Inertia-Seite. Schreibe sie nicht in React um.
Wenn der Host keine MCP-Apps rendern kann, sind die Klassen und Pest-Tests immer noch der Beweis.
Auth und die Webserver
Sowohl /mcp/tickets als auch /mcp/directory verwenden auth:api und throttle:mcp. Der mcp-Limiter ist 60 pro Minute in AppServiceProvider, schlüsselbasiert auf Benutzer-ID oder IP.
User implementiert OAuthenticatable und verwendet Passport HasApiTokens. Das Fehlen des Interfaces ist der übliche Passport-Setup-Fehler.
Eine nicht authentifizierte POST-Anfrage gibt 401 mit WWW-Authenticate zurück, das auf die geschützte Ressourcen-Metadaten dieses Pfads verweist. Discovery deckt sowohl /mcp/tickets als auch /mcp/directory ab. Dynamische Registrierung ist POST /oauth/register. Ein Zugriffstoken funktioniert auf beiden Servern.
Der Genehmigungs- und Ablehnungsbildschirm ist resources/views/mcp/authorize.blade.php. Lass ihn in Ruhe.
Dashboard
Fortify und Inertia kommen aus dem Starter-Kit. Ersetze sie nicht.
/dashboard ist zwei Tabellen. Tickets zeigen id, Betreff, Status, Priorität, Anfragenden und Bearbeiter. Personen zeigen id, Name, Rolle, Team und Bereitschaft. Wayfinder benennt die Route. DashboardController übergibt Inertia-Props. Keine Formulare, die Tickets erstellen.
Beweis, dass ein Schreib-Tool funktioniert hat, durch Aktualisieren von /dashboard. Führe composer run dev aus, wenn du dich um die React-Seite kümmerst.
Passport-Autorisierung und das QueueApp-iframe bleiben Blade. Das Paket besitzt diese.
Wie du damit sprichst
Cursor, lokal. .cursor/mcp.json startet beide Server aus dem Projektstamm.
{
"mcpServers": {
"northwind-tickets": {
"command": "php",
"args": ["artisan", "mcp:start", "tickets"]
},
"northwind-directory": {
"command": "php",
"args": ["artisan", "mcp:start", "directory"]
}
}
}Tickets ist die tägliche Schleife. Du brauchst directory in Cursor nicht für die Client-Arbeit. Der Ticket-Server-Prozess startet es.
Lokale Tickets laufen als Sam. Um Ada oder Root zu sehen, verwende Pest actingAs oder den Webserver mit ihrem Token.
Inspector. php artisan mcp:inspector tickets und php artisan mcp:inspector mcp/tickets. Verwende dies, wenn ein Tool nichts tut und du das rohe Ergebnis brauchst. Web-Inspector benötigt ein Passport-Bearer-Token. Die Inspector-UI ist auf :6274, diese App ist auf :8000. CORS für mcp/*, oauth/* und .well-known/* lebt in config/cors.php. Ohne diese Pfade geben die Discovery-GETs 200 zurück und der Browser verwirft sie trotzdem.
Dashboard. Beweis, dass die Schreib-Tools Eloquent treffen.
Pest. php artisan test --compact tests/Feature/Mcp.
Rufe nicht Client::web('http://127.0.0.1:8000/mcp/directory') aus einem Ticket-Tool auf, während artisan serve der einzige PHP-Prozess ist. Es wird hängen bleiben. composer run dev ändert das nicht. Ein zweiter PHP-Server auf :8001 plus Client::web ist ein optionales späteres Experiment, nicht der Standard.
Dieses Repo testen
Feature-Tests leben in tests/Feature/Mcp. TestCase bindet den directory-Client immer neu an InProcessDirectoryTransport, damit SQLite :memory: sichtbar ist. Ein Test in DirectoryClientTest verwendet den echten stdio-Client, um Tools aufzulisten. Der Unplugged-Client-Test zeigt den benannten Client auf php -r 'exit(1);'.
Helfer in tests/Helpers.php:
mcpRpc($response)für das rohe JSON-RPC-ArraymcpToolContent($response)für die Ergebnis-InhaltslistemcpTicketsCall($name, $arguments)für einentools/call-HTTP-Body
Um zu behaupten, dass ein Tool versteckt ist, TicketsServer::actingAs($user) und dann (new TicketsServer(new FakeTransporter))->createContext()->tools(). Rufe nicht handle() auf einem versteckten Tool auf und erwarte einen Policy-Fehler. Das Aufrufen ist ein JSON-RPC-Fehler „nicht gefunden“.
Lies Annotationen aus $tool->annotations(), nicht aus toArray()['annotations']. Tool::toArray() typisiert dieses Feld als array|object. Ressourcen-toArray() lässt Annotationen aus.
Friere Zeit mit Carbon::setTestNow ein. Ohne pest-plugin-phpstan ist $this TestCall und $this->travelTo typprüft nicht.
Importiere Pest\Laravel\postJson und Pest\Laravel\withToken. Rufe sie nicht auf $this auf.
Was die Suite abdeckt:
Anfragender kann nicht zuweisen oder löschen
Agent kann zuweisen, nicht löschen
Admin kann löschen
delete_ticketfehlt, wenn als Sam agiert, vorhanden für RootInterne Eskalationsressource vor Ada verborgen
search_kbverbirgt interne Artikel vor Anfragendenweekly_reviewnur für Mitarbeiterassign_ticketschlägt fehl, wenn der Directory-Client die Person nicht finden kannValidierungsfehler sind Sätze
close_stale_ticketsgibt Fortschrittsbenachrichtigungen ausOAuth-Discovery, Registrierung, Autorisierungs-Blade, PKCE, dann ein Tool-Aufruf auf beiden Webservern
Starter-Kit-Fortify-Tests bleiben. Schreibe sie nicht um, um MCP zu beweisen.
Dateilayout
app/
Enums/Role.php Status.php Priority.php Visibility.php
Models/User.php Team.php Ticket.php Comment.php Article.php
Policies/TicketPolicy.php ArticlePolicy.php
Repositories/DirectoryRepository.php TicketRepository.php ArticleRepository.php
Http/Controllers/DashboardController.php
Mcp/
Servers/DirectoryServer.php TicketsServer.php
Tools/ (15 tools)
Resources/ (11 resources, including QueueApp)
Prompts/ (3 prompts)
routes/ai.php
resources/js/pages/dashboard.tsx
resources/views/mcp/authorize.blade.php
resources/views/mcp/queue-app.blade.php
database/seeders/LabSeeder.php
tests/Feature/Mcp/
tests/Helpers.php
tests/Support/InProcessDirectoryTransport.php
.ai/rules/ (settled decisions for the next agent)Stehende Fallen
Diese sind bereits in .ai/rules. Sie gehören auch hierher, weil sie leicht wieder zu brechen sind.
Ticket-Tools sprechen mit DirectoryServer über
Client::local. Tests überschreiben das mit dem In-Process-Transport.Frage
UseroderTeamnicht ausassign_ticketoderget_ticketab. Verschiebe diese Suche nicht inTicketRepository.PHPDoc-Verzeichnis
$resourcesalsclass-string<Server\Resource>. Pint schreibtclass-string<Resource>in den PHP-Typresourceklein.ai.phpbleibt außerhalb der Web-Middleware-Gruppe.QueueApp ist
ui://.desk://queueist Markdown.KB-Artikel gehen durch
ArticleRepository. Fehlende und verbotenedesk://kb/{slug}-Lesezugriffe verwenden denselben Fehler.
Dokumentation
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Personal assistant MCP server with search, execute, packages, jobs, secrets, and integrations.
Hosted MCP endpoint with realistic fake data for prototyping agents. 12 tools, no setup.
MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/jaksa-v/mcp-lab'
If you have feedback or need assistance with the MCP directory API, please join our Discord server