Northwind Tickets
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Northwind Ticketsshow me open tickets"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-lab
A Laravel app that exists to force every part of Laravel MCP. Fake helpdesk. Fake company. Throw it away when you are done.
This is the default reference for how MCP works in Laravel and how this repo uses it.
This is a gym, not a product. If you catch yourself picking a typeface or inventing a billing page, stop.
How Laravel MCP works
Model Context Protocol is JSON-RPC. An AI host lists what your server exposes, then calls it. Cursor and Inspector are the hosts this repo cares about. Laravel MCP, laravel/mcp 0.9.x here, is the wrapper.
You do not invent a protocol. You write PHP classes and register them.
Servers
A server is a class that extends Laravel\Mcp\Server. It is a catalog of tools, resources, and 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], and #[Icon] are metadata the host shows the model. Instructions are the system prompt for that server. Keep them short and operational.
Create one with php artisan make:mcp-server. Register it in routes/ai.php. Laravel loads that file on its own. Do not add it to bootstrap/app.php.
Local vs web
Same server class. Two ways in.
Mcp::local('tickets', TicketsServer::class);
Mcp::web('/mcp/tickets', TicketsServer::class)->middleware(['auth:api', 'throttle:mcp']);Local is stdio. The host runs php artisan mcp:start tickets as a child process. That is the daily Cursor loop. No HTTP session, no cookies, no Passport.
Web is HTTP JSON-RPC at that path. Inspector and remote hosts use it. Middleware applies, and this is where OAuth lives.
Do not wrap routes/ai.php in the web middleware group. CSRF will block Inspector.
Tools, resources, and prompts
A server can expose tools, resources, and prompts. Generate them with make:mcp-tool, make:mcp-resource, and make:mcp-prompt. Then add the class to the server arrays. An unregistered class does nothing.
Tools are actions. The model calls them with arguments. schema() is the JSON Schema the host advertises. handle(Request $request) does the work. $request->validate() is ordinary Laravel validation. Write messages a model can act on, like Give me the ticket id, like 12., not The ticket_id field is required.
Resources are readable documents at a URI. A static URI uses #[Uri('desk://playbook')]. Templates implement HasUriTemplate and read variables with $request->get('id'). MIME type uses #[MimeType]. The host can list them and read them without calling a tool.
Prompts are reusable message templates. arguments() declares what the host should collect. handle() returns messages, usually an assistant instruction plus a user message that includes real data. The model then writes from that.
Constructor-inject repositories. Do not put queries in the tool class. handle() can also type-hint Laravel services.
Responses
handle() returns a Laravel\Mcp\Response, a response factory, an array of responses, or a Generator.
Shape | How |
Text |
|
Error |
|
Structured JSON |
|
Several text blocks |
|
Resource link |
|
Blob from disk |
|
HTML app |
|
Progress |
|
Response::structured cannot be empty and returns a factory. To mix structured JSON with resource links, build both and attach the payload with withStructuredContent. That is what list_tickets does.
Class $meta is metadata on the tool itself. ->withMeta([...]) is metadata on one response. create_ticket has the first. get_ticket has the second.
Annotations
Hints for the host. They do not enforce anything in PHP. Policies still do.
Attribute | Meaning here |
| Does not write |
| Safe to retry |
| Deletes or wrecks state |
| May touch the outside world. |
| Resource hints |
| This tool opens an MCP App |
shouldRegister(Request $request): bool hides a tool, resource, or prompt from the list. If the host still tries to call a hidden one, the server returns not-found. Use it for role gates. delete_ticket is admin-only. Still check $request->user()->can(...) inside handle(). Listing and executing are different doors.
Authorization
$request->user() is the signed-in user, same as a controller. Call $user->can('update', $ticket) and return Response::error('Permission denied.'). Do not invent a second auth system.
Local servers have no HTTP session. This app's ticket server signs in as Sam in boot() when Auth is empty. Web servers get the user from Passport.
The MCP client
Laravel can call an MCP server too. Named clients live in a service provider.
Mcp::registerClient('directory', fn () => Client::local('php', [
'artisan', 'mcp:start', 'directory',
]));Then ticket code does Mcp::client('directory')->callTool('get_person', ['id' => $id]) or ->readResource('directory://people/'.$id).
Client::local spawns a process. Client::web($url) is HTTP. Same-app HTTP against php artisan serve deadlocks because that process is single-threaded. Use local for same-app calls.
MCP Apps
An AppResource returns a self-contained HTML document at a ui:// URI. A tool marked #[RendersApp(resource: QueueApp::class)] tells a capable host to fetch that HTML and put it in a sandboxed iframe.
The Blade view uses <x-mcp::app>. That component ships the client SDK. Inside the iframe, createMcpApp gives you app.callServerTool(...). Vite and React do not apply. Tailwind and Alpine come from #[AppMeta(libraries: [Library::Tailwind, Library::Alpine])].
Visibility::App hides a tool from the model so only the iframe can call it. get_queue_data is that tool.
Cursor lists these tools. It does not render the iframe. Pest is how you know the classes work.
Auth on the web
The Laravel docs offer Sanctum and Passport. Sanctum is a bearer token. Passport is OAuth 2.1, which is what the protocol specifies.
This app uses Passport. Mcp::oauthRoutes() registers discovery and dynamic client registration. Web routes use auth:api. Laravel MCP advertises a single mcp:use scope. Publish mcp-views and point Passport::authorizationView at resources/views/mcp/authorize.blade.php. Leave that Blade alone.
Testing
Inspector is for poking. Pest is how you know policies hold.
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']);Template resources take the URI variables as the second argument. The helper expands desk://tickets/{id}.
assertSee only reads text and structured data. Resource links and _meta live on the raw JSON-RPC payload. This repo's mcpRpc() and mcpToolContent() in tests/Helpers.php read that. Generator tools use assertSentNotification and assertNotificationCount. The final result still holds the text payloads.
Web auth is an HTTP test. POST /mcp/tickets with mcpTicketsCall(). Unauthenticated requests must be 401, not a login redirect.
What this repo is
Northwind Support. One Laravel 13 app, PHP 8.4, SQLite. Inertia and React are the dump page only. The agent is the write path.
Two MCP servers, each registered twice, local and 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 is read-only people and teams. TicketsServer is the helpdesk. Ticket tools must not query User or Team through Eloquent. They look people up through the named directory client, get_person and directory://people/{id}. That is why there are two servers. If you User::find() from a ticket tool, you skipped the point.
Queries live in DirectoryRepository and TicketRepository. Tools inject those.
Domain
Three roles on users.role.
Role | What they can do |
| Open tickets, comment on their own, read public KB |
| See every ticket, assign, comment, change status, read internal KB |
| Everything an agent can, plus delete tickets and the weekly-review prompt |
Policies are ordinary Laravel policies. TicketPolicy covers view, update, comment, and delete. ArticlePolicy hides internal articles from requesters. Staff is Role::isStaff(), agent or admin.
Login users come from LabSeeder. Password for all of them is password.
Role | |
| requester |
| agent |
| admin |
email_verified_at is set. Fortify has verification enabled. User does not implement MustVerifyEmail, so you will not be blocked.
Jonah Hale, jonah@northwind.test, is the on-call agent.
Tables
Keep them small. If a column is not needed by a tool, it is not there.
users. Starter-kit columns 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). Four rows. Public ones are refunds and shipping. Internal ones are escalation and refund-abuse.
LabSeeder runs from DatabaseSeeder. It is idempotent enough to re-run after migrate:fresh. Twenty tickets, thirty comments, eight people, one PNG at storage/app/badge.png.
DirectoryServer
Read-only. The instructions say so. Name Northwind Directory, version 0.0.1, teal icon.
Tools
Tool | What it does |
| Search by name, email, or title. Optional team slug. Structured |
| Look up one user id. Custom validation messages. Structured |
| No required args. Two text blocks, names then slugs |
| Returns the |
Resources
URI | What it does |
| Static markdown. |
| Person dossier. |
| Team dossier. Second template so the first is not a one-off |
| Who is on call. |
| Tiny PNG via |
No prompts here. They belong on the ticket server, where they have something to say.
TicketsServer
Name Northwind Tickets, version 0.0.1, dark icon.
boot() signs in as sam@northwind.test when nobody is authenticated. Local Cursor has no HTTP session. Without this, every tool would say You must be signed in. Web requests already have a Passport user, so boot() returns early.
Tools
Tool | What it does |
| Lists tickets the user can see. Status and priority filters. Structured output plus |
| One ticket. Assignee and requester names come from |
| Opens a ticket. Class |
| Adds a comment after the |
|
|
| Sets |
|
|
| Closes every |
| Searches articles the current user can see. Structured |
| Model-visible. |
| Same app, |
list_tickets cannot use TicketResource::uri() for links. That method returns the template desk://tickets/{id}. Links must be the expanded string.
assign_ticket and get_ticket refuse to touch User. If the directory client is unplugged, assign fails. Pest proves that.
Prompts
Prompt | What it does |
| Takes |
| Takes |
| Recap of open tickets. |
Resources
URI | What it does |
| Static markdown, high priority. How to triage |
| Markdown list of open tickets the current user can see |
| Markdown dossier with comments |
| One article. Missing and forbidden slugs return the same error |
| Staff-only listing of the internal escalation article |
| Interactive queue iframe |
desk://queue is markdown. QueueApp is the iframe. Do not confuse them.
QueueApp
QueueApp extends AppResource. Blade at resources/views/mcp/queue-app.blade.php. Alpine inside <x-mcp::app>. Refresh calls get_queue_data through app.callServerTool.
This is not an Inertia page. Do not rewrite it in React.
If the host cannot render MCP Apps, the classes and Pest tests are still the proof.
Auth and the web servers
Both /mcp/tickets and /mcp/directory use auth:api and throttle:mcp. The mcp limiter is 60 per minute in AppServiceProvider, keyed by user id or IP.
User implements OAuthenticatable and uses Passport HasApiTokens. Missing the interface is the usual Passport setup bug.
An unauthenticated POST returns 401 with WWW-Authenticate pointing at that path's protected-resource metadata. Discovery covers both /mcp/tickets and /mcp/directory. Dynamic registration is POST /oauth/register. One access token works on both servers.
The approve and deny screen is resources/views/mcp/authorize.blade.php. Leave it.
Dashboard
Fortify and Inertia come from the starter kit. Do not replace them.
/dashboard is two tables. Tickets show id, subject, status, priority, requester, and assignee. People show id, name, role, team, and on call. Wayfinder names the route. DashboardController passes Inertia props. No forms that create tickets.
Proof a write tool worked by refreshing /dashboard. Run composer run dev when you care about the React page.
Passport authorize and the QueueApp iframe stay Blade. The package owns those.
How you talk to it
Cursor, local. .cursor/mcp.json starts both servers from the project root.
{
"mcpServers": {
"northwind-tickets": {
"command": "php",
"args": ["artisan", "mcp:start", "tickets"]
},
"northwind-directory": {
"command": "php",
"args": ["artisan", "mcp:start", "directory"]
}
}
}Tickets is the daily loop. You do not need directory in Cursor for the client work. The ticket server process spawns it.
Local tickets run as Sam. To see Ada or Root, use Pest actingAs or the web server with their token.
Inspector. php artisan mcp:inspector tickets and php artisan mcp:inspector mcp/tickets. Use this when a tool does nothing and you need the raw result. Web Inspector needs a Passport bearer token. The Inspector UI is on :6274, this app is on :8000. CORS for mcp/*, oauth/*, and .well-known/* lives in config/cors.php. Without those paths the discovery GETs return 200 and the browser still throws them away.
Dashboard. Proof the write tools hit Eloquent.
Pest. php artisan test --compact tests/Feature/Mcp.
Do not Client::web('http://127.0.0.1:8000/mcp/directory') from a ticket tool while artisan serve is the only PHP process. It will hang. composer run dev does not change that. A second PHP server on :8001 plus Client::web is an optional later experiment, not the default.
Testing this repo
Feature tests live in tests/Feature/Mcp. TestCase always rebinds the directory client to InProcessDirectoryTransport so SQLite :memory: is visible. One test in DirectoryClientTest uses the real stdio client to list tools. The unplugged-client test points the named client at php -r 'exit(1);'.
Helpers in tests/Helpers.php:
mcpRpc($response)for the raw JSON-RPC arraymcpToolContent($response)for the result content listmcpTicketsCall($name, $arguments)for atools/callHTTP body
To assert a tool is hidden, TicketsServer::actingAs($user) then (new TicketsServer(new FakeTransporter))->createContext()->tools(). Do not call handle() on a hidden tool and expect a policy error. Invoking it is a not-found JSON-RPC error.
Read annotations from $tool->annotations(), not toArray()['annotations']. Tool::toArray() types that field as array|object. Resource toArray() omits annotations.
Freeze time with Carbon::setTestNow. Without pest-plugin-phpstan, $this is TestCall and $this->travelTo does not typecheck.
Import Pest\Laravel\postJson and Pest\Laravel\withToken. Do not call them on $this.
What the suite covers:
Requester cannot assign or delete
Agent can assign, cannot delete
Admin can delete
delete_ticketabsent when acting as Sam, present for RootInternal escalation resource hidden from Ada
search_kbhides internal articles from requestersweekly_reviewstaff-onlyassign_ticketfails if the directory client cannot find the personValidation errors are sentences
close_stale_ticketsyields progress notificationsOAuth discovery, registration, authorize Blade, PKCE, then a tool call on both web servers
Starter-kit Fortify tests stay. Do not rewrite them to prove MCP.
File layout
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)Standing traps
These are already in .ai/rules. They belong here too because they are easy to re-break.
Ticket tools talk to DirectoryServer through
Client::local. Tests override that with the in-process transport.Do not query
UserorTeamfromassign_ticketorget_ticket. Do not move that lookup intoTicketRepository.PHPDoc directory
$resourcesasclass-string<Server\Resource>. Pint lowercasesclass-string<Resource>to PHP'sresourcetype.ai.phpstays out of the web middleware group.QueueApp is
ui://.desk://queueis markdown.KB articles go through
ArticleRepository. Missing and forbiddendesk://kb/{slug}reads use the same error.
Docs
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