Northwind Tickets
mcp-lab
Una aplicación Laravel que existe para forzar cada parte de Laravel MCP. Helpdesk falso. Empresa falsa. Tírala cuando termines.
Esta es la referencia predeterminada de cómo funciona MCP en Laravel y cómo este repositorio lo usa.
Esto es un gimnasio, no un producto. Si te sorprendes eligiendo una tipografía o inventando una página de facturación, detente.
Cómo funciona Laravel MCP
Model Context Protocol es JSON-RPC. Un host de IA lista lo que tu servidor expone y luego lo llama. Cursor e Inspector son los hosts que le importan a este repositorio. Laravel MCP, laravel/mcp 0.9.x aquí, es el envoltorio.
No inventas un protocolo. Escribes clases PHP y las registras.
Servidores
Un servidor es una clase que extiende Laravel\Mcp\Server. Es un catálogo de herramientas, recursos y 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] e #[Icon] son metadatos que el host muestra al modelo. Las instrucciones son el prompt de sistema para ese servidor. Mantenlas cortas y operativas.
Crea uno con php artisan make:mcp-server. Regístralo en routes/ai.php. Laravel carga ese archivo por su cuenta. No lo agregues a bootstrap/app.php.
Local vs web
La misma clase de servidor. Dos formas de entrada.
Mcp::local('tickets', TicketsServer::class);
Mcp::web('/mcp/tickets', TicketsServer::class)->middleware(['auth:api', 'throttle:mcp']);Local es stdio. El host ejecuta php artisan mcp:start tickets como proceso hijo. Ese es el bucle diario de Cursor. Sin sesión HTTP, sin cookies, sin Passport.
Web es HTTP JSON-RPC en esa ruta. Inspector y los hosts remotos lo usan. El middleware se aplica, y aquí es donde vive OAuth.
No envuelvas routes/ai.php en el grupo de middleware web. CSRF bloqueará a Inspector.
Herramientas, recursos y prompts
Un servidor puede exponer herramientas, recursos y prompts. Genéralos con make:mcp-tool, make:mcp-resource y make:mcp-prompt. Luego agrega la clase a los arrays del servidor. Una clase no registrada no hace nada.
Las herramientas son acciones. El modelo las llama con argumentos. schema() es el JSON Schema que el host anuncia. handle(Request $request) hace el trabajo. $request->validate() es validación ordinaria de Laravel. Escribe mensajes sobre los que un modelo pueda actuar, como Dame el id del ticket, como 12., no El campo ticket_id es obligatorio.
Los recursos son documentos legibles en una URI. Una URI estática usa #[Uri('desk://playbook')]. Las plantillas implementan HasUriTemplate y leen variables con $request->get('id'). El tipo MIME usa #[MimeType]. El host puede listarlos y leerlos sin llamar a una herramienta.
Los prompts son plantillas de mensajes reutilizables. arguments() declara lo que el host debe recopilar. handle() devuelve mensajes, generalmente una instrucción de asistente más un mensaje de usuario que incluye datos reales. Luego el modelo escribe a partir de eso.
Inyecta repositorios en el constructor. No pongas consultas en la clase de la herramienta. handle() también puede usar type-hint de servicios de Laravel.
Respuestas
handle() devuelve un Laravel\Mcp\Response, una fábrica de respuestas, un array de respuestas o un Generator.
Forma | Cómo |
Texto |
|
Error |
|
JSON estructurado |
|
Varios bloques de texto |
|
Enlace a recurso |
|
Blob desde disco |
|
Aplicación HTML |
|
Progreso |
|
Response::structured no puede estar vacío y devuelve una fábrica. Para mezclar JSON estructurado con enlaces a recursos, construye ambos y adjunta el payload con withStructuredContent. Eso es lo que hace list_tickets.
La clase $meta es metadatos sobre la herramienta en sí. ->withMeta([...]) es metadatos sobre una respuesta. create_ticket tiene lo primero. get_ticket tiene lo segundo.
Anotaciones
Pistas para el host. No imponen nada en PHP. Las políticas sí lo hacen.
Atributo | Significado aquí |
| No escribe |
| Seguro de reintentar |
| Elimina o destruye estado |
| Puede tocar el mundo exterior. |
| Pistas de recurso |
| Esta herramienta abre una MCP App |
shouldRegister(Request $request): bool oculta una herramienta, recurso o prompt de la lista. Si el host aún intenta llamar a uno oculto, el servidor devuelve no encontrado. Úsalo para puertas de roles. delete_ticket es solo para administradores. Aún así verifica $request->user()->can(...) dentro de handle(). Listar y ejecutar son puertas diferentes.
Autorización
$request->user() es el usuario autenticado, igual que en un controlador. Llama a $user->can('update', $ticket) y devuelve Response::error('Permission denied.'). No inventes un segundo sistema de autenticación.
Los servidores locales no tienen sesión HTTP. El servidor de tickets de esta aplicación inicia sesión como Sam en boot() cuando Auth está vacío. Los servidores web obtienen el usuario de Passport.
El cliente MCP
Laravel también puede llamar a un servidor MCP. Los clientes con nombre viven en un proveedor de servicios.
Mcp::registerClient('directory', fn () => Client::local('php', [
'artisan', 'mcp:start', 'directory',
]));Luego el código de tickets hace Mcp::client('directory')->callTool('get_person', ['id' => $id]) o ->readResource('directory://people/'.$id).
Client::local genera un proceso. Client::web($url) es HTTP. HTTP de la misma aplicación contra php artisan serve se bloquea porque ese proceso es de un solo hilo. Usa local para llamadas de la misma aplicación.
MCP Apps
Un AppResource devuelve un documento HTML autocontenido en una URI ui://. Una herramienta marcada con #[RendersApp(resource: QueueApp::class)] le dice a un host capaz que obtenga ese HTML y lo ponga en un iframe con sandbox.
La vista Blade usa <x-mcp::app>. Ese componente incluye el SDK del cliente. Dentro del iframe, createMcpApp te da app.callServerTool(...). Vite y React no aplican. Tailwind y Alpine vienen de #[AppMeta(libraries: [Library::Tailwind, Library::Alpine])].
Visibility::App oculta una herramienta del modelo para que solo el iframe pueda llamarla. get_queue_data es esa herramienta.
Cursor lista estas herramientas. No renderiza el iframe. Pest es cómo sabes que las clases funcionan.
Autenticación en la web
La documentación de Laravel ofrece Sanctum y Passport. Sanctum es un token bearer. Passport es OAuth 2.1, que es lo que especifica el protocolo.
Esta aplicación usa Passport. Mcp::oauthRoutes() registra descubrimiento y registro dinámico de clientes. Las rutas web usan auth:api. Laravel MCP anuncia un único alcance mcp:use. Publica mcp-views y apunta Passport::authorizationView a resources/views/mcp/authorize.blade.php. Deja ese Blade en paz.
Pruebas
Inspector es para tocar. Pest es cómo sabes que las políticas se mantienen.
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']);Los recursos de plantilla toman las variables de URI como segundo argumento. El helper expande desk://tickets/{id}.
assertSee solo lee texto y datos estructurados. Los enlaces a recursos y _meta viven en el payload JSON-RPC crudo. mcpRpc() y mcpToolContent() de este repositorio en tests/Helpers.php leen eso. Las herramientas generadoras usan assertSentNotification y assertNotificationCount. El resultado final aún contiene los payloads de texto.
La autenticación web es una prueba HTTP. POST /mcp/tickets con mcpTicketsCall(). Las solicitudes no autenticadas deben ser 401, no una redirección de inicio de sesión.
Qué es este repositorio
Northwind Support. Una aplicación Laravel 13, PHP 8.4, SQLite. Inertia y React son solo la página de volcado. El agente es la ruta de escritura.
Dos servidores MCP, cada uno registrado dos veces, local y 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 es solo lectura de personas y equipos. TicketsServer es el helpdesk. Las herramientas de tickets no deben consultar User o Team a través de Eloquent. Buscan personas a través del cliente con nombre directory, get_person y directory://people/{id}. Por eso hay dos servidores. Si haces User::find() desde una herramienta de tickets, te saltaste el punto.
Las consultas viven en DirectoryRepository y TicketRepository. Las herramientas los inyectan.
Dominio
Tres roles en users.role.
Rol | Qué pueden hacer |
| Abrir tickets, comentar en los suyos, leer la KB pública |
| Ver cada ticket, asignar, comentar, cambiar estado, leer la KB interna |
| Todo lo que puede un agente, más eliminar tickets y el prompt de revisión semanal |
Las políticas son políticas ordinarias de Laravel. TicketPolicy cubre ver, actualizar, comentar y eliminar. ArticlePolicy oculta artículos internos a los solicitantes. El personal es Role::isStaff(), agente o administrador.
Los usuarios de inicio de sesión vienen de LabSeeder. La contraseña para todos ellos es password.
Rol | |
| requester |
| agent |
| admin |
email_verified_at está establecido. Fortify tiene verificación habilitada. User no implementa MustVerifyEmail, así que no serás bloqueado.
Jonah Hale, jonah@northwind.test, es el agente de guardia.
Tablas
Mantenlas pequeñas. Si una columna no la necesita una herramienta, no está ahí.
users. Columnas del kit de inicio más role (requester\|agent\|admin), team_id anulable, title, on_call.
teams. name, slug. Soporte, Facturación, Almacén.
tickets. subject, body, status (open\|pending\|closed), priority (low\|normal\|high\|urgent), requester_id, assignee_id anulable, team_id anulable.
comments. ticket_id, user_id, body.
articles. slug, title, body, visibility (public\|internal). Cuatro filas. Las públicas son reembolsos y envíos. Las internas son escalamiento y abuso de reembolsos.
LabSeeder se ejecuta desde DatabaseSeeder. Es lo suficientemente idempotente para volver a ejecutarse después de migrate:fresh. Veinte tickets, treinta comentarios, ocho personas, un PNG en storage/app/badge.png.
DirectoryServer
Solo lectura. Las instrucciones lo dicen. Nombre Northwind Directory, versión 0.0.1, icono verde azulado.
Herramientas
Herramienta | Qué hace |
| Busca por nombre, correo electrónico o cargo. Slug de equipo opcional. |
| Consulta un id de usuario. Mensajes de validación personalizados. |
| Sin argumentos obligatorios. Dos bloques de texto: nombres y luego slugs |
| Devuelve el usuario |
Recursos
URI | Qué hace |
| Markdown estático. |
| Expediente de persona. |
| Expediente de equipo. Segundo template para que el primero no sea un caso único |
| Quién está de guardia. |
| PNG pequeño mediante |
Aquí no hay prompts. Pertenecen al servidor de tickets, donde tienen algo que decir.
TicketsServer
Nombre Northwind Tickets, versión 0.0.1, icono oscuro.
boot() inicia sesión como sam@northwind.test cuando nadie está autenticado. Cursor local no tiene sesión HTTP. Sin esto, cada herramienta diría You must be signed in. Las peticiones web ya tienen un usuario de Passport, así que boot() retorna antes.
Herramientas
Herramienta | Qué hace |
| Lista los tickets que el usuario puede ver. Filtros de estado y prioridad. Salida estructurada más enlaces de recurso |
| Un ticket. Los nombres de asignado y solicitante vienen de |
| Abre un ticket. El |
| Añade un comentario tras la comprobación de la política |
|
|
| Establece |
|
|
| Cierra todos los tickets |
| Busca artículos que el usuario actual puede ver. |
| Visible para el modelo. |
| Misma app, |
list_tickets no puede usar TicketResource::uri() para los enlaces. Ese método devuelve la plantilla desk://tickets/{id}. Los enlaces deben ser la cadena expandida.
assign_ticket y get_ticket se niegan a tocar User. Si el cliente de directorio está desenchufado, la asignación falla. Pest lo demuestra.
Prompts
Prompt | Qué hace |
| Toma |
| Toma |
| Resumen de tickets abiertos. |
Recursos
URI | Qué hace |
| Markdown estático, prioridad alta. Cómo hacer triaje |
| Lista en Markdown de tickets abiertos que el usuario actual puede ver |
| Expediente en Markdown con comentarios |
| Un artículo. Los slugs ausentes y prohibidos devuelven el mismo error |
| Listado solo para personal del artículo de escalado interno |
| Iframe interactivo de la cola |
desk://queue es Markdown. QueueApp es el iframe. No los confundas.
QueueApp
QueueApp extiende AppResource. Blade en resources/views/mcp/queue-app.blade.php. Alpine dentro de <x-mcp::app>. El refresco llama a get_queue_data mediante app.callServerTool.
No es una página Inertia. No la reescribas en React.
Si el host no puede renderizar MCP Apps, las clases y las pruebas de Pest siguen siendo la prueba.
Autenticación y los servidores web
Tanto /mcp/tickets como /mcp/directory usan auth:api y throttle:mcp. El limitador mcp es de 60 por minuto en AppServiceProvider, con clave por id de usuario o IP.
User implementa OAuthenticatable y usa HasApiTokens de Passport. La falta de la interfaz es el error típico de configuración de Passport.
Un POST no autenticado devuelve 401 con WWW-Authenticate apuntando a los metadatos de recurso protegido de esa ruta. El descubrimiento cubre tanto /mcp/tickets como /mcp/directory. El registro dinámico es POST /oauth/register. Un solo token de acceso funciona en ambos servidores.
La pantalla de aprobar y denegar es resources/views/mcp/authorize.blade.php. Déjala.
Panel de control
Fortify e Inertia vienen del starter kit. No los reemplaces.
/dashboard son dos tablas. Los tickets muestran id, asunto, estado, prioridad, solicitante y asignado. Las personas muestran id, nombre, rol, equipo y guardia. Wayfinder nombra la ruta. DashboardController pasa props de Inertia. No hay formularios que creen tickets.
Prueba que una herramienta de escritura funcionó refrescando /dashboard. Ejecuta composer run dev cuando te importe la página React.
La autorización de Passport y el iframe de QueueApp siguen siendo Blade. El paquete es dueño de esos.
Cómo hablar con ello
Cursor, local. .cursor/mcp.json inicia ambos servidores desde la raíz del proyecto.
{
"mcpServers": {
"northwind-tickets": {
"command": "php",
"args": ["artisan", "mcp:start", "tickets"]
},
"northwind-directory": {
"command": "php",
"args": ["artisan", "mcp:start", "directory"]
}
}
}Tickets es el bucle diario. No necesitas directory en Cursor para el trabajo de cliente. El proceso del servidor de tickets lo genera.
Los tickets locales se ejecutan como Sam. Para ver a Ada o Root, usa Pest actingAs o el servidor web con su token.
Inspector. php artisan mcp:inspector tickets y php artisan mcp:inspector mcp/tickets. Úsalo cuando una herramienta no haga nada y necesites el resultado crudo. El Inspector web necesita un token bearer de Passport. La UI del Inspector está en :6274, esta app en :8000. CORS para mcp/*, oauth/* y .well-known/* vive en config/cors.php. Sin esas rutas, los GET de descubrimiento devuelven 200 y el navegador igualmente los descarta.
Panel de control. Prueba que las herramientas de escritura tocan Eloquent.
Pest. php artisan test --compact tests/Feature/Mcp.
No hagas Client::web('http://127.0.0.1:8000/mcp/directory') desde una herramienta de tickets mientras artisan serve es el único proceso PHP. Se colgará. composer run dev no cambia eso. Un segundo servidor PHP en :8001 más Client::web es un experimento opcional posterior, no el predeterminado.
Probar este repositorio
Las pruebas de características viven en tests/Feature/Mcp. TestCase siempre reenlaza el cliente directory a InProcessDirectoryTransport para que SQLite :memory: sea visible. Una prueba en DirectoryClientTest usa el cliente stdio real para listar herramientas. La prueba de cliente desenchufado apunta el cliente nombrado a php -r 'exit(1);'.
Helpers en tests/Helpers.php:
mcpRpc($response)para el array JSON-RPC crudomcpToolContent($response)para la lista de contenido del resultadomcpTicketsCall($name, $arguments)para un cuerpo HTTP detools/call
Para afirmar que una herramienta está oculta, TicketsServer::actingAs($user) y luego (new TicketsServer(new FakeTransporter))->createContext()->tools(). No llames a handle() en una herramienta oculta esperando un error de política. Invocarla es un error JSON-RPC de no encontrado.
Lee las anotaciones de $tool->annotations(), no de toArray()['annotations']. Tool::toArray() tipa ese campo como array|object. El toArray() de Resource omite anotaciones.
Congela el tiempo con Carbon::setTestNow. Sin pest-plugin-phpstan, $this es TestCall y $this->travelTo no pasa el typecheck.
Importa Pest\Laravel\postJson y Pest\Laravel\withToken. No los llames sobre $this.
Lo que cubre la suite:
El solicitante no puede asignar ni eliminar
El agente puede asignar, no puede eliminar
El administrador puede eliminar
delete_ticketausente al actuar como Sam, presente para RootRecurso de escalado interno oculto para Ada
search_kboculta artículos internos a los solicitantesweekly_reviewsolo para personalassign_ticketfalla si el cliente de directorio no encuentra a la personaLos errores de validación son frases
close_stale_ticketsemite notificaciones de progresoDescubrimiento OAuth, registro, Blade de autorización, PKCE y luego una llamada a herramienta en ambos servidores web
Las pruebas de Fortify del starter kit se quedan. No las reescribas para probar MCP.
Estructura de archivos
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)Trampas habituales
Estas ya están en .ai/rules. También pertenecen aquí porque es fácil volver a romperlas.
Las herramientas de tickets hablan con DirectoryServer mediante
Client::local. Las pruebas lo sobrescriben con el transporte en proceso.No consultes
UserniTeamdesdeassign_ticketoget_ticket. No muevas esa búsqueda aTicketRepository.Documenta
$resourcesdel directorio comoclass-string<Server\Resource>. Pint convierteclass-string<Resource>a minúsculas al tiporesourcede PHP.ai.phpse mantiene fuera del grupo de middleware web.QueueApp es
ui://.desk://queuees Markdown.Los artículos de KB pasan por
ArticleRepository. Las lecturas ausentes y prohibidas dedesk://kb/{slug}usan el mismo error.
Documentación
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